test: cover chat rules, UI and end-to-end behavior
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e802ccf760
commit
aa9b5e332a
7 files changed
+1526
No files matched your search
@@ -0,0 +1,464 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Channels;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Chat;
|
||||
using FamilyPlanner.Shared.Admin;
|
||||
using FamilyPlanner.Shared.Chat;
|
||||
using FamilyPlanner.Shared.Dashboard;
|
||||
using Microsoft.AspNetCore.Http.Connections;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace FamilyPlanner.IntegrationTests;
|
||||
|
||||
/// <summary>Chat end-to-end über den BFF: Rechte je Rolle, Familienchat, Direktnachrichten, Paging, Bilder, Reaktionen, Gelesen-Stand, SignalR.</summary>
|
||||
[Collection(SettingsCollection.Name)]
|
||||
public sealed class ChatTests(AppFixture fixture)
|
||||
{
|
||||
private static readonly byte[] Png = Convert.FromBase64String(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==");
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private sealed record Person(UserAdminDto User, HttpClient Client) : IDisposable
|
||||
{
|
||||
public Guid Id => User.Id;
|
||||
|
||||
public void Dispose() => Client.Dispose();
|
||||
}
|
||||
|
||||
private async Task<Person> NewAsync(string role)
|
||||
{
|
||||
var (user, client) = await fixture.CreateSignedInUserAsync(role, Ct);
|
||||
return new Person(user, client);
|
||||
}
|
||||
|
||||
private static async Task<ChatChannelListDto> ChannelsAsync(HttpClient client) =>
|
||||
(await client.GetFromJsonAsync<ChatChannelListDto>("/api/chat/channels", Ct))!;
|
||||
|
||||
private static async Task<ChatChannelDto> FamilyChannelAsync(HttpClient client) =>
|
||||
(await ChannelsAsync(client)).Channels.Single(c => c.Kind == ChatChannelKind.Family);
|
||||
|
||||
private static async Task<ChatChannelDto> StartDirectAsync(HttpClient from, Guid to)
|
||||
{
|
||||
var response = await from.PostAsJsonAsync("/api/chat/direct", new StartDirectChatRequest(to), Ct);
|
||||
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync(Ct));
|
||||
return (await response.Content.ReadFromJsonAsync<ChatChannelDto>(Ct))!;
|
||||
}
|
||||
|
||||
private static async Task<ChatMessageDto> SendAsync(HttpClient client, Guid channelId, string text, Guid? id = null)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync($"/api/chat/channels/{channelId}/messages", new SendChatMessageRequest(id ?? Guid.NewGuid(), text), Ct);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.Created, await response.Content.ReadAsStringAsync(Ct));
|
||||
return (await response.Content.ReadFromJsonAsync<ChatMessageDto>(Ct))!;
|
||||
}
|
||||
|
||||
private static Task<ChatMessagePageDto> PageAsync(HttpClient client, Guid channelId, Guid? before = null, int? take = null) =>
|
||||
client.GetFromJsonAsync<ChatMessagePageDto>(
|
||||
$"/api/chat/channels/{channelId}/messages?{(before is { } b ? $"before={b}&" : string.Empty)}{(take is { } t ? $"take={t}" : string.Empty)}", Ct)!;
|
||||
|
||||
private static async Task<int> UnreadAsync(HttpClient client, Guid channelId) =>
|
||||
(await ChannelsAsync(client)).Channels.Single(c => c.Id == channelId).UnreadCount;
|
||||
|
||||
private static MultipartFormDataContent Image(byte[] data, string fileName = "foto.png", string contentType = "image/png", Guid? id = null, string? text = null)
|
||||
{
|
||||
var form = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(data);
|
||||
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
|
||||
form.Add(file, "file", fileName);
|
||||
if (id is { } messageId)
|
||||
{
|
||||
form.Add(new StringContent(messageId.ToString()), "messageId");
|
||||
}
|
||||
|
||||
if (text is not null)
|
||||
{
|
||||
form.Add(new StringContent(text), "text");
|
||||
}
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chat_requires_authentication()
|
||||
{
|
||||
using var anonymous = fixture.CreateBrowserClient();
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/chat/channels", Ct)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Family_channel_is_shared_by_family_members_unread_counts_and_read_state_follow()
|
||||
{
|
||||
using var mom = await NewAsync(Roles.Family);
|
||||
using var kid = await NewAsync(Roles.Child);
|
||||
var channel = await FamilyChannelAsync(mom.Client);
|
||||
var kidBefore = await UnreadAsync(kid.Client, channel.Id);
|
||||
var momBefore = await UnreadAsync(mom.Client, channel.Id);
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var sent = await SendAsync(mom.Client, channel.Id, " Pizza\r\nheute Abend? ", id);
|
||||
Assert.Equal(("Pizza\nheute Abend?", mom.User.DisplayName, mom.Id), (sent.Text, sent.SenderName, sent.SenderId));
|
||||
Assert.Null(sent.ImageUrl);
|
||||
|
||||
// Wiederholtes Senden (Netzfehler/Doppelklick) legt keine zweite Nachricht an.
|
||||
var repeated = await mom.Client.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/messages", new SendChatMessageRequest(id, "anderer Text"), Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, repeated.StatusCode);
|
||||
Assert.Equal("Pizza\nheute Abend?", (await repeated.Content.ReadFromJsonAsync<ChatMessageDto>(Ct))!.Text);
|
||||
Assert.Single((await PageAsync(kid.Client, channel.Id, take: 100)).Messages, m => m.Id == id);
|
||||
|
||||
Assert.Equal(kidBefore + 1, await UnreadAsync(kid.Client, channel.Id));
|
||||
Assert.Equal(momBefore, await UnreadAsync(mom.Client, channel.Id)); // eigene Nachrichten zählen nicht
|
||||
Assert.True((await ChannelsAsync(kid.Client)).TotalUnread >= 1);
|
||||
|
||||
// Gelesen-Stand: bis zur angezeigten Nachricht, nie rückwärts.
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await kid.Client.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/read", new MarkChatReadRequest(id), Ct)).StatusCode);
|
||||
Assert.Equal(0, await UnreadAsync(kid.Client, channel.Id));
|
||||
var first = (await PageAsync(kid.Client, channel.Id)).ReadStates.Single(r => r.UserId == kid.Id).LastReadAt;
|
||||
var older = (await PageAsync(kid.Client, channel.Id, take: 100)).Messages.First().Id;
|
||||
await kid.Client.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/read", new MarkChatReadRequest(older), Ct);
|
||||
Assert.Equal(first, (await PageAsync(kid.Client, channel.Id)).ReadStates.Single(r => r.UserId == kid.Id).LastReadAt);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await kid.Client.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/read", new MarkChatReadRequest(Guid.NewGuid()), Ct)).StatusCode);
|
||||
|
||||
// Validierung
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await mom.Client.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/messages", new SendChatMessageRequest(Guid.NewGuid(), " "), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest,
|
||||
(await mom.Client.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/messages", new SendChatMessageRequest(Guid.NewGuid(), new string('x', ChatMessage.TextMaxLength + 1)), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await mom.Client.PostAsJsonAsync($"/api/chat/channels/{Guid.NewGuid()}/messages", new SendChatMessageRequest(Guid.NewGuid(), "Hi"), Ct)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Guests_have_no_chat_unless_enabled_and_then_only_the_family_channel()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
using var mom = await NewAsync(Roles.Family);
|
||||
using var guest = await NewAsync(Roles.Guest);
|
||||
var channel = await FamilyChannelAsync(mom.Client);
|
||||
|
||||
Assert.Empty((await ChannelsAsync(guest.Client)).Channels);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await guest.Client.GetAsync($"/api/chat/channels/{channel.Id}/messages", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await guest.Client.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/messages", new SendChatMessageRequest(Guid.NewGuid(), "Hallo"), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await guest.Client.PostAsJsonAsync("/api/chat/direct", new StartDirectChatRequest(mom.Id), Ct)).StatusCode);
|
||||
Assert.Null((await guest.Client.GetFromJsonAsync<DashboardDto>("/api/dashboard", Ct))!.Chat);
|
||||
|
||||
var settings = (await admin.GetFromJsonAsync<FamilySettingsDto>("/api/settings", Ct))!;
|
||||
await admin.PutAsJsonAsync("/api/admin/settings", settings with { GuestChatEnabled = true }, Ct);
|
||||
try
|
||||
{
|
||||
Assert.Equal(ChatChannelKind.Family, Assert.Single((await ChannelsAsync(guest.Client)).Channels).Kind);
|
||||
var hello = await SendAsync(guest.Client, channel.Id, "Hallo aus dem Gästezimmer");
|
||||
Assert.Contains((await PageAsync(mom.Client, channel.Id, take: 100)).Messages, m => m.Id == hello.Id);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await guest.Client.PostAsJsonAsync("/api/chat/direct", new StartDirectChatRequest(mom.Id), Ct)).StatusCode);
|
||||
Assert.NotNull((await guest.Client.GetFromJsonAsync<DashboardDto>("/api/dashboard", Ct))!.Chat);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await admin.PutAsJsonAsync("/api/admin/settings", settings, Ct);
|
||||
}
|
||||
|
||||
// Rechte wirken sofort: nach dem Abschalten ist der Kanal wieder verborgen.
|
||||
Assert.Empty((await ChannelsAsync(guest.Client)).Channels);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await guest.Client.GetAsync($"/api/chat/channels/{channel.Id}/messages", Ct)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Direct_messages_are_private_between_two_people_and_never_involve_guests()
|
||||
{
|
||||
using var mom = await NewAsync(Roles.Family);
|
||||
using var kid = await NewAsync(Roles.Child);
|
||||
using var sibling = await NewAsync(Roles.Child);
|
||||
using var guest = await NewAsync(Roles.Guest);
|
||||
|
||||
// Regeln: Kinder schreiben Familienmitgliedern (auch Kindern), nie Gästen; Gäste starten nichts; nicht an sich selbst.
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await kid.Client.PostAsJsonAsync("/api/chat/direct", new StartDirectChatRequest(guest.Id), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await mom.Client.PostAsJsonAsync("/api/chat/direct", new StartDirectChatRequest(guest.Id), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await kid.Client.PostAsJsonAsync("/api/chat/direct", new StartDirectChatRequest(kid.Id), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await kid.Client.PostAsJsonAsync("/api/chat/direct", new StartDirectChatRequest(Guid.NewGuid()), Ct)).StatusCode);
|
||||
|
||||
var channel = await StartDirectAsync(kid.Client, mom.Id);
|
||||
Assert.Equal((ChatChannelKind.Direct, mom.User.DisplayName, mom.Id), (channel.Kind, channel.Title, channel.PartnerId));
|
||||
var again = await StartDirectAsync(mom.Client, kid.Id); // gleicher Kanal von der anderen Seite
|
||||
Assert.Equal(channel.Id, again.Id);
|
||||
|
||||
// Bis zur ersten Nachricht ist der Kanal nur für den Anlegenden sichtbar.
|
||||
Assert.Contains((await ChannelsAsync(kid.Client)).Channels, c => c.Id == channel.Id);
|
||||
Assert.DoesNotContain((await ChannelsAsync(sibling.Client)).Channels, c => c.Id == channel.Id);
|
||||
|
||||
var sent = await SendAsync(kid.Client, channel.Id, "Darf ich noch aufbleiben?");
|
||||
var momView = (await ChannelsAsync(mom.Client)).Channels.Single(c => c.Id == channel.Id);
|
||||
Assert.Equal((kid.User.DisplayName, 1), (momView.Title, momView.UnreadCount));
|
||||
Assert.Equal("Darf ich noch aufbleiben?", momView.LastMessage!.Text);
|
||||
|
||||
// Dritte sehen und schreiben nichts.
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await sibling.Client.GetAsync($"/api/chat/channels/{channel.Id}/messages", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await sibling.Client.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/messages", new SendChatMessageRequest(Guid.NewGuid(), "mitlesen"), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await sibling.Client.PutAsJsonAsync($"/api/chat/messages/{sent.Id}/reactions", new SetChatReactionRequest("👍", true), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await sibling.Client.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/read", new MarkChatReadRequest(), Ct)).StatusCode);
|
||||
|
||||
// Gelesen-Haken: die Mutter hat gelesen → Kind sieht ihren Stand.
|
||||
await mom.Client.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/read", new MarkChatReadRequest(sent.Id), Ct);
|
||||
var states = (await PageAsync(kid.Client, channel.Id)).ReadStates;
|
||||
Assert.True(states.Single(s => s.UserId == mom.Id).LastReadAt >= sent.CreatedAt);
|
||||
Assert.Equal(0, await UnreadAsync(mom.Client, channel.Id));
|
||||
|
||||
// Dashboard-Kachel zählt ungelesene Nachrichten mit.
|
||||
await SendAsync(kid.Client, channel.Id, "Bitte!");
|
||||
Assert.Equal(1, (await mom.Client.GetFromJsonAsync<DashboardDto>("/api/dashboard", Ct))!.Chat!.UnreadCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task History_is_paged_with_stable_order_and_has_more_flag()
|
||||
{
|
||||
using var mom = await NewAsync(Roles.Family);
|
||||
using var dad = await NewAsync(Roles.Family);
|
||||
var channel = await StartDirectAsync(mom.Client, dad.Id);
|
||||
var ids = new List<Guid>();
|
||||
for (var i = 1; i <= 7; i++)
|
||||
{
|
||||
ids.Add((await SendAsync(i % 2 == 0 ? dad.Client : mom.Client, channel.Id, $"Nachricht {i}")).Id);
|
||||
}
|
||||
|
||||
var last = await PageAsync(dad.Client, channel.Id, take: 3);
|
||||
Assert.Equal(["Nachricht 5", "Nachricht 6", "Nachricht 7"], last.Messages.Select(m => m.Text));
|
||||
Assert.True(last.HasMore);
|
||||
|
||||
var middle = await PageAsync(dad.Client, channel.Id, last.Messages[0].Id, 3);
|
||||
Assert.Equal(["Nachricht 2", "Nachricht 3", "Nachricht 4"], middle.Messages.Select(m => m.Text));
|
||||
Assert.True(middle.HasMore);
|
||||
|
||||
var oldest = await PageAsync(dad.Client, channel.Id, middle.Messages[0].Id, 3);
|
||||
Assert.Equal(["Nachricht 1"], oldest.Messages.Select(m => m.Text));
|
||||
Assert.False(oldest.HasMore);
|
||||
|
||||
Assert.Equal(7, (await PageAsync(dad.Client, channel.Id)).Messages.Count);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await dad.Client.GetAsync($"/api/chat/channels/{channel.Id}/messages?before={Guid.NewGuid()}", Ct)).StatusCode);
|
||||
Assert.Equal(100, ChatRules.PageSize(100_000));
|
||||
|
||||
// Gelöschte Nachrichten tauchen nicht mehr auf, auch nicht als Cursor-Ziel für weitere Seiten.
|
||||
await mom.Client.DeleteAsync($"/api/chat/messages/{ids[0]}", Ct);
|
||||
Assert.DoesNotContain((await PageAsync(dad.Client, channel.Id, take: 100)).Messages, m => m.Id == ids[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Images_are_validated_by_signature_stored_privately_and_removed_with_the_message()
|
||||
{
|
||||
using var mom = await NewAsync(Roles.Family);
|
||||
using var kid = await NewAsync(Roles.Child);
|
||||
using var sibling = await NewAsync(Roles.Child);
|
||||
var channel = await StartDirectAsync(mom.Client, kid.Id);
|
||||
var messageId = Guid.NewGuid();
|
||||
|
||||
var response = await mom.Client.PostAsync($"/api/chat/channels/{channel.Id}/images", Image(Png, id: messageId, text: " Urlaubsfoto "), Ct);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var message = (await response.Content.ReadFromJsonAsync<ChatMessageDto>(Ct))!;
|
||||
Assert.Equal((messageId, "Urlaubsfoto", $"/api/chat/attachments/{messageId}"), (message.Id, message.Text, message.ImageUrl));
|
||||
|
||||
// Wiederholung mit derselben Id: keine zweite Nachricht.
|
||||
Assert.Equal(HttpStatusCode.OK, (await mom.Client.PostAsync($"/api/chat/channels/{channel.Id}/images", Image(Png, id: messageId), Ct)).StatusCode);
|
||||
Assert.Single((await PageAsync(kid.Client, channel.Id)).Messages);
|
||||
|
||||
// Nur Beteiligte laden das Bild; der Typ kommt aus der Signatur.
|
||||
var image = await kid.Client.GetAsync(message.ImageUrl, Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, image.StatusCode);
|
||||
Assert.Equal("image/png", image.Content.Headers.ContentType!.MediaType);
|
||||
Assert.Equal(Png, await image.Content.ReadAsByteArrayAsync(Ct));
|
||||
Assert.Equal("nosniff", image.Headers.GetValues("X-Content-Type-Options").Single());
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await sibling.Client.GetAsync(message.ImageUrl, Ct)).StatusCode);
|
||||
using (var anonymous = fixture.CreateBrowserClient())
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync(message.ImageUrl, Ct)).StatusCode);
|
||||
}
|
||||
|
||||
// Falsche Typen: der Client behauptet PNG, es ist aber Text/HTML/ZIP – oder die Datei fehlt/ist zu groß.
|
||||
foreach (var fake in new[] { "<html><script>alert(1)</script></html>"u8.ToArray(), [0x50, 0x4B, 0x03, 0x04, 0, 0, 0, 0, 0, 0, 0, 0], [0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0, 0, 0, 0, 0]})
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await mom.Client.PostAsync($"/api/chat/channels/{channel.Id}/images", Image(fake), Ct)).StatusCode);
|
||||
}
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await mom.Client.PostAsync($"/api/chat/channels/{channel.Id}/images", Image([]), Ct)).StatusCode);
|
||||
var tooBig = new byte[ChatRules.MaxImageBytes + 1024];
|
||||
Png.CopyTo(tooBig, 0);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await mom.Client.PostAsync($"/api/chat/channels/{channel.Id}/images", Image(tooBig), Ct)).StatusCode);
|
||||
var huge = new byte[ChatRules.MaxImageBytes + 1024 * 1024];
|
||||
Png.CopyTo(huge, 0);
|
||||
Assert.False((await mom.Client.PostAsync($"/api/chat/channels/{channel.Id}/images", Image(huge), Ct)).IsSuccessStatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await mom.Client.PostAsync($"/api/chat/channels/{channel.Id}/images", new MultipartFormDataContent { { new StringContent("x"), "text" } }, Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await sibling.Client.PostAsync($"/api/chat/channels/{channel.Id}/images", Image(Png), Ct)).StatusCode);
|
||||
|
||||
// Löschen entfernt Nachricht und Bild.
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await mom.Client.DeleteAsync($"/api/chat/messages/{messageId}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await kid.Client.GetAsync(message.ImageUrl, Ct)).StatusCode);
|
||||
Assert.Empty((await PageAsync(kid.Client, channel.Id)).Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reactions_are_state_based_limited_to_the_allowed_set_and_visible_to_the_channel()
|
||||
{
|
||||
using var mom = await NewAsync(Roles.Family);
|
||||
using var kid = await NewAsync(Roles.Child);
|
||||
var channel = await StartDirectAsync(mom.Client, kid.Id);
|
||||
var message = await SendAsync(mom.Client, channel.Id, "Abendessen ist fertig");
|
||||
|
||||
var set = await kid.Client.PutAsJsonAsync($"/api/chat/messages/{message.Id}/reactions", new SetChatReactionRequest("👍", true), Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, set.StatusCode);
|
||||
await kid.Client.PutAsJsonAsync($"/api/chat/messages/{message.Id}/reactions", new SetChatReactionRequest("👍", true), Ct); // idempotent
|
||||
var both = (await (await mom.Client.PutAsJsonAsync($"/api/chat/messages/{message.Id}/reactions", new SetChatReactionRequest("👍", true), Ct))
|
||||
.Content.ReadFromJsonAsync<List<ChatReactionDto>>(Ct))!;
|
||||
Assert.Equal(("👍", 2), (both.Single().Emoji, both.Single().UserIds.Count));
|
||||
await mom.Client.PutAsJsonAsync($"/api/chat/messages/{message.Id}/reactions", new SetChatReactionRequest("❤️", true), Ct);
|
||||
|
||||
var shown = (await PageAsync(kid.Client, channel.Id)).Messages.Single().Reactions;
|
||||
Assert.Equal(["👍", "❤️"], shown.Select(r => r.Emoji));
|
||||
Assert.Equal([2, 1], shown.Select(r => r.UserIds.Count));
|
||||
|
||||
var removed = (await (await kid.Client.PutAsJsonAsync($"/api/chat/messages/{message.Id}/reactions", new SetChatReactionRequest("👍", false), Ct))
|
||||
.Content.ReadFromJsonAsync<List<ChatReactionDto>>(Ct))!;
|
||||
Assert.Equal([mom.Id], removed.Single(r => r.Emoji == "👍").UserIds);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await kid.Client.PutAsJsonAsync($"/api/chat/messages/{message.Id}/reactions", new SetChatReactionRequest("<script>", true), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await kid.Client.PutAsJsonAsync($"/api/chat/messages/{message.Id}/reactions", new SetChatReactionRequest("🔥", true), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await kid.Client.PutAsJsonAsync($"/api/chat/messages/{Guid.NewGuid()}/reactions", new SetChatReactionRequest("👍", true), Ct)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_is_allowed_for_the_sender_and_for_admins_in_the_family_channel_only()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
using var mom = await NewAsync(Roles.Family);
|
||||
using var kid = await NewAsync(Roles.Child);
|
||||
var family = await FamilyChannelAsync(mom.Client);
|
||||
|
||||
var fromKid = await SendAsync(kid.Client, family.Id, "Ich war's nicht");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await mom.Client.DeleteAsync($"/api/chat/messages/{fromKid.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/chat/messages/{fromKid.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/chat/messages/{fromKid.Id}", Ct)).StatusCode); // idempotent
|
||||
Assert.DoesNotContain((await PageAsync(mom.Client, family.Id, take: 100)).Messages, m => m.Id == fromKid.Id);
|
||||
|
||||
// Direktnachrichten moderiert niemand mit – auch Admins nicht (sie sehen den Kanal nicht einmal).
|
||||
var direct = await StartDirectAsync(kid.Client, mom.Id);
|
||||
var secret = await SendAsync(kid.Client, direct.Id, "Geheim");
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await admin.DeleteAsync($"/api/chat/messages/{secret.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await mom.Client.DeleteAsync($"/api/chat/messages/{secret.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await kid.Client.DeleteAsync($"/api/chat/messages/{secret.Id}", Ct)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivated_partner_keeps_the_history_but_can_no_longer_be_written_to()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
using var mom = await NewAsync(Roles.Family);
|
||||
using var kid = await NewAsync(Roles.Child);
|
||||
var channel = await StartDirectAsync(mom.Client, kid.Id);
|
||||
await SendAsync(mom.Client, channel.Id, "Bis später");
|
||||
|
||||
var deleted = await admin.DeleteAsync($"/api/admin/users/{kid.Id}", Ct);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
|
||||
|
||||
var view = (await ChannelsAsync(mom.Client)).Channels.Single(c => c.Id == channel.Id);
|
||||
Assert.False(view.CanSend);
|
||||
Assert.Single((await PageAsync(mom.Client, channel.Id)).Messages);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await mom.Client.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/messages", new SendChatMessageRequest(Guid.NewGuid(), "Hallo?"), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await mom.Client.PostAsJsonAsync("/api/chat/direct", new StartDirectChatRequest(kid.Id), Ct)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_events_reach_only_current_members_and_typing_is_relayed_without_echo()
|
||||
{
|
||||
var (mom, momClient) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var (kid, kidClient) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (outsider, outsiderClient) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
using var _m = momClient;
|
||||
using var _k = kidClient;
|
||||
using var _o = outsiderClient;
|
||||
var channel = await StartDirectAsync(momClient, kid.Id);
|
||||
|
||||
await using var kidHub = await ListenAsync(kid.UserName);
|
||||
await using var momHub = await ListenAsync(mom.UserName);
|
||||
await using var outsiderHub = await ListenAsync(outsider.UserName);
|
||||
|
||||
var sent = await SendAsync(momClient, channel.Id, "Live!");
|
||||
var received = await ReadAsync(kidHub.Messages.Reader, m => m.Id == sent.Id);
|
||||
Assert.Equal(("Live!", channel.Id), (received.Text, received.ChannelId));
|
||||
await ReadAsync(momHub.Messages.Reader, m => m.Id == sent.Id); // auch der Absender (weitere Geräte)
|
||||
|
||||
// Reaktion, Gelesen-Stand und Löschen als Ereignisse
|
||||
await kidClient.PutAsJsonAsync($"/api/chat/messages/{sent.Id}/reactions", new SetChatReactionRequest("🎉", true), Ct);
|
||||
var reactions = await ReadAsync(momHub.Reactions.Reader, r => r.MessageId == sent.Id);
|
||||
Assert.Equal(("🎉", kid.Id), (reactions.Reactions.Single().Emoji, reactions.Reactions.Single().UserIds.Single()));
|
||||
await kidClient.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/read", new MarkChatReadRequest(sent.Id), Ct);
|
||||
Assert.Equal(kid.Id, (await ReadAsync(momHub.Reads.Reader, r => r.User == kid.Id)).User);
|
||||
|
||||
// Tippen: geht an die anderen, nicht zurück; Fremde und unbekannte Kanäle bleiben stumm.
|
||||
await kidHub.Connection.InvokeAsync(ChatEvents.TypingMethod, channel.Id, Ct);
|
||||
Assert.Equal(kid.Id, (await ReadAsync(momHub.Typing.Reader, t => t.Channel == channel.Id)).User);
|
||||
await outsiderHub.Connection.InvokeAsync(ChatEvents.TypingMethod, channel.Id, Ct);
|
||||
await kidHub.Connection.InvokeAsync(ChatEvents.TypingMethod, Guid.NewGuid(), Ct);
|
||||
|
||||
await momClient.DeleteAsync($"/api/chat/messages/{sent.Id}", Ct);
|
||||
Assert.Equal(sent.Id, await ReadAsync(kidHub.Removed.Reader, id => id == sent.Id));
|
||||
|
||||
Assert.False(outsiderHub.Messages.Reader.TryRead(out _));
|
||||
Assert.False(outsiderHub.Reactions.Reader.TryRead(out _));
|
||||
Assert.False(outsiderHub.Removed.Reader.TryRead(out _));
|
||||
Assert.False(kidHub.Typing.Reader.TryRead(out _));
|
||||
}
|
||||
|
||||
private async Task<Listener> ListenAsync(string userName)
|
||||
{
|
||||
var cookies = new System.Net.CookieContainer();
|
||||
using var login = fixture.CreateApiClient(cookies);
|
||||
await AppFixture.LoginAsync(login, userName, AppFixture.UserPassword, Ct);
|
||||
var listener = new Listener();
|
||||
listener.Connection = new HubConnectionBuilder()
|
||||
.WithUrl(new Uri(fixture.WebBaseAddress, "/hubs/live"), options =>
|
||||
{
|
||||
options.Cookies = cookies;
|
||||
options.Transports = HttpTransportType.WebSockets | HttpTransportType.LongPolling;
|
||||
options.HttpMessageHandlerFactory = _ => new HttpClientHandler
|
||||
{
|
||||
CookieContainer = cookies,
|
||||
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
|
||||
};
|
||||
options.WebSocketConfiguration = ws => ws.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||
})
|
||||
.Build();
|
||||
listener.Connection.On<ChatMessageDto>(ChatEvents.MessageReceived, m => listener.Messages.Writer.TryWrite(m));
|
||||
listener.Connection.On<Guid, Guid>(ChatEvents.MessageRemoved, (_, id) => listener.Removed.Writer.TryWrite(id));
|
||||
listener.Connection.On<ChatReactionsChangedDto>(ChatEvents.ReactionsChanged, r => listener.Reactions.Writer.TryWrite(r));
|
||||
listener.Connection.On<Guid, Guid, DateTimeOffset>(ChatEvents.ReadChanged, (channel, user, at) => listener.Reads.Writer.TryWrite((channel, user, at)));
|
||||
listener.Connection.On<Guid, Guid>(ChatEvents.Typing, (channel, user) => listener.Typing.Writer.TryWrite((channel, user)));
|
||||
await listener.Connection.StartAsync(Ct);
|
||||
return listener;
|
||||
}
|
||||
|
||||
private sealed class Listener : IAsyncDisposable
|
||||
{
|
||||
public HubConnection Connection { get; set; } = null!;
|
||||
|
||||
public Channel<ChatMessageDto> Messages { get; } = Channel.CreateUnbounded<ChatMessageDto>();
|
||||
|
||||
public Channel<Guid> Removed { get; } = Channel.CreateUnbounded<Guid>();
|
||||
|
||||
public Channel<ChatReactionsChangedDto> Reactions { get; } = Channel.CreateUnbounded<ChatReactionsChangedDto>();
|
||||
|
||||
public Channel<(Guid Channel, Guid User, DateTimeOffset At)> Reads { get; } = Channel.CreateUnbounded<(Guid, Guid, DateTimeOffset)>();
|
||||
|
||||
public Channel<(Guid Channel, Guid User)> Typing { get; } = Channel.CreateUnbounded<(Guid, Guid)>();
|
||||
|
||||
public ValueTask DisposeAsync() => Connection.DisposeAsync();
|
||||
}
|
||||
|
||||
private static async Task<T> ReadAsync<T>(ChannelReader<T> reader, Func<T, bool> predicate)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
while (true)
|
||||
{
|
||||
var value = await reader.ReadAsync(timeout.Token);
|
||||
if (predicate(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using FamilyPlanner.Api.Features.Chat;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
using FamilyPlanner.Domain.Chat;
|
||||
using FamilyPlanner.Shared.Chat;
|
||||
|
||||
namespace FamilyPlanner.UnitTests.Chat;
|
||||
|
||||
/// <summary>Chat-Regeln: Zugriff je Rolle, Direktnachrichten, Text, Bilder, Reaktionen, Löschen, Paging.</summary>
|
||||
public sealed class ChatRulesTests
|
||||
{
|
||||
private static readonly PermissionOptions GuestChat = new(GuestChatEnabled: true);
|
||||
|
||||
[Theory]
|
||||
[InlineData(Roles.Admin, true)]
|
||||
[InlineData(Roles.Family, true)]
|
||||
[InlineData(Roles.Child, true)]
|
||||
[InlineData(Roles.Guest, false)]
|
||||
public void Family_chat_access_per_role_without_exceptions(string role, bool allowed) =>
|
||||
Assert.Equal(allowed, ChatRules.CanUseFamilyChat([role]));
|
||||
|
||||
[Fact]
|
||||
public void Guests_use_the_family_chat_only_with_the_setting_and_never_direct_messages()
|
||||
{
|
||||
Assert.True(ChatRules.CanUseFamilyChat([Roles.Guest], GuestChat));
|
||||
Assert.False(ChatRules.CanUseDirectMessages([Roles.Guest], GuestChat));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Roles.Admin, true)]
|
||||
[InlineData(Roles.Family, true)]
|
||||
[InlineData(Roles.Child, true)]
|
||||
[InlineData(Roles.Guest, false)]
|
||||
[InlineData("Unbekannt", false)]
|
||||
public void Direct_message_access_per_role(string role, bool allowed) =>
|
||||
Assert.Equal(allowed, ChatRules.CanUseDirectMessages([role]));
|
||||
|
||||
[Theory]
|
||||
[InlineData(Roles.Admin, Roles.Family, true)]
|
||||
[InlineData(Roles.Family, Roles.Child, true)]
|
||||
[InlineData(Roles.Child, Roles.Family, true)]
|
||||
[InlineData(Roles.Child, Roles.Child, true)]
|
||||
[InlineData(Roles.Child, Roles.Admin, true)]
|
||||
[InlineData(Roles.Child, Roles.Guest, false)]
|
||||
[InlineData(Roles.Guest, Roles.Child, false)]
|
||||
[InlineData(Roles.Family, Roles.Guest, false)]
|
||||
[InlineData(Roles.Guest, Roles.Guest, false)]
|
||||
public void Direct_messages_need_two_non_guest_participants(string sender, string recipient, bool allowed)
|
||||
{
|
||||
Assert.Equal(allowed, ChatRules.CanDirectMessage(sender, recipient));
|
||||
Assert.Equal(allowed, ChatRules.CanDirectMessage(recipient, sender)); // symmetrisch
|
||||
Assert.Equal(allowed, ChatRules.CanDirectMessage(sender, recipient, GuestChat)); // Gast-Ausnahme betrifft nur den Familienchat
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chat_permissions_in_the_matrix_agree_with_the_rules()
|
||||
{
|
||||
foreach (var role in Roles.All)
|
||||
{
|
||||
Assert.Equal(PermissionMatrix.For(Permission.FamilyChat, role) != AccessLevel.None, ChatRules.CanUseFamilyChat([role]));
|
||||
Assert.Equal(PermissionMatrix.For(Permission.DirectMessages, role) != AccessLevel.None, ChatRules.CanUseDirectMessages([role]));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Direct_key_is_independent_of_the_order_and_unique_per_pair()
|
||||
{
|
||||
var a = Guid.NewGuid();
|
||||
var b = Guid.NewGuid();
|
||||
|
||||
Assert.Equal(ChatRules.DirectKey(a, b), ChatRules.DirectKey(b, a));
|
||||
Assert.NotEqual(ChatRules.DirectKey(a, b), ChatRules.DirectKey(a, Guid.NewGuid()));
|
||||
Assert.True(ChatRules.DirectKey(a, b).Length <= ChatChannel.DirectKeyMaxLength);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(" \r\n ", null)]
|
||||
[InlineData(" Hallo ", "Hallo")]
|
||||
[InlineData("Zeile 1\r\nZeile 2\rZeile 3", "Zeile 1\nZeile 2\nZeile 3")]
|
||||
public void Text_is_trimmed_and_line_endings_are_normalized(string? input, string? expected) =>
|
||||
Assert.Equal(expected, ChatRules.NormalizeText(input));
|
||||
|
||||
[Fact]
|
||||
public void Message_validation_rejects_empty_and_too_long_text()
|
||||
{
|
||||
var validator = new SendChatMessageRequestValidator();
|
||||
|
||||
Assert.True(validator.Validate(new SendChatMessageRequest(Guid.NewGuid(), "Hi")).IsValid);
|
||||
Assert.True(validator.Validate(new SendChatMessageRequest(Guid.NewGuid(), new string('x', ChatMessage.TextMaxLength))).IsValid);
|
||||
Assert.False(validator.Validate(new SendChatMessageRequest(Guid.NewGuid(), " ")).IsValid);
|
||||
Assert.False(validator.Validate(new SendChatMessageRequest(Guid.NewGuid(), new string('x', ChatMessage.TextMaxLength + 1))).IsValid);
|
||||
Assert.False(validator.Validate(new SendChatMessageRequest(Guid.Empty, "Hi")).IsValid);
|
||||
Assert.Contains(validator.Validate(new SendChatMessageRequest(Guid.NewGuid(), "")).Errors, e => e.PropertyName == "Text");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reaction_and_direct_chat_requests_are_validated()
|
||||
{
|
||||
Assert.True(new SetChatReactionRequestValidator().Validate(new SetChatReactionRequest("👍", true)).IsValid);
|
||||
Assert.False(new SetChatReactionRequestValidator().Validate(new SetChatReactionRequest("🔥", true)).IsValid);
|
||||
Assert.False(new SetChatReactionRequestValidator().Validate(new SetChatReactionRequest("", false)).IsValid);
|
||||
Assert.False(new StartDirectChatRequestValidator().Validate(new StartDirectChatRequest(Guid.Empty)).IsValid);
|
||||
Assert.True(new StartDirectChatRequestValidator().Validate(new StartDirectChatRequest(Guid.NewGuid())).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_fixed_reaction_set_is_allowed()
|
||||
{
|
||||
Assert.All(ChatRules.Reactions, e => Assert.True(ChatRules.IsAllowedReaction(e)));
|
||||
Assert.All(ChatRules.Reactions, e => Assert.True(e.Length <= ChatReaction.EmojiMaxLength));
|
||||
Assert.Equal(ChatRules.Reactions.Count, ChatRules.Reactions.Distinct().Count());
|
||||
Assert.False(ChatRules.IsAllowedReaction("👍👍"));
|
||||
Assert.False(ChatRules.IsAllowedReaction("<b>"));
|
||||
Assert.False(ChatRules.IsAllowedReaction(null));
|
||||
Assert.False(ChatRules.IsAllowedReaction("👍 "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Images_are_recognized_by_signature_not_by_client_claims()
|
||||
{
|
||||
byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
|
||||
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 0];
|
||||
byte[] webp = [.. "RIFF"u8, 0, 0, 0, 0, .. "WEBP"u8];
|
||||
|
||||
Assert.Equal("image/png", ChatRules.ValidateImage(png).ContentType);
|
||||
Assert.Equal("image/jpeg", ChatRules.ValidateImage(jpeg).ContentType);
|
||||
Assert.Equal("image/webp", ChatRules.ValidateImage(webp).ContentType);
|
||||
|
||||
foreach (var fake in new[] { "<svg onload=alert(1)>"u8.ToArray(), "GIF89a......"u8.ToArray(), "%PDF-1.7..."u8.ToArray(), [0x4D, 0x5A, 0x90, 0x00] })
|
||||
{
|
||||
var (type, error) = ChatRules.ValidateImage(fake);
|
||||
Assert.Null(type);
|
||||
Assert.Contains("PNG, JPEG oder WebP", error);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Image_size_is_limited_and_empty_files_are_rejected()
|
||||
{
|
||||
var atLimit = new byte[ChatRules.MaxImageBytes];
|
||||
new byte[] { 0xFF, 0xD8, 0xFF }.CopyTo(atLimit, 0);
|
||||
var tooBig = new byte[ChatRules.MaxImageBytes + 1];
|
||||
new byte[] { 0xFF, 0xD8, 0xFF }.CopyTo(tooBig, 0);
|
||||
|
||||
Assert.Equal("image/jpeg", ChatRules.ValidateImage(atLimit).ContentType);
|
||||
Assert.Contains("zu groß", ChatRules.ValidateImage(tooBig).Error);
|
||||
Assert.Contains("leer", ChatRules.ValidateImage([]).Error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, ChatChannelKind.Family)]
|
||||
[InlineData(false, ChatChannelKind.Direct)]
|
||||
[InlineData(true, ChatChannelKind.Family)]
|
||||
[InlineData(true, ChatChannelKind.Direct)]
|
||||
public void Senders_may_always_delete_their_own_messages(bool admin, ChatChannelKind kind)
|
||||
{
|
||||
var me = Guid.NewGuid();
|
||||
|
||||
Assert.True(ChatRules.CanDelete(me, me, admin, kind));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, ChatChannelKind.Family, false)]
|
||||
[InlineData(false, ChatChannelKind.Direct, false)]
|
||||
[InlineData(true, ChatChannelKind.Family, true)]
|
||||
[InlineData(true, ChatChannelKind.Direct, false)]
|
||||
public void Only_admins_moderate_foreign_messages_and_only_in_the_family_chat(bool admin, ChatChannelKind kind, bool expected) =>
|
||||
Assert.Equal(expected, ChatRules.CanDelete(Guid.NewGuid(), Guid.NewGuid(), admin, kind));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 30)]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(-5, 1)]
|
||||
[InlineData(10, 10)]
|
||||
[InlineData(100, 100)]
|
||||
[InlineData(5000, 100)]
|
||||
public void Page_size_is_clamped(int? requested, int expected) => Assert.Equal(expected, ChatRules.PageSize(requested));
|
||||
|
||||
[Fact]
|
||||
public void Unread_baseline_is_the_last_read_marker_or_the_account_creation()
|
||||
{
|
||||
var created = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var read = created.AddDays(10);
|
||||
|
||||
Assert.Equal(read, ChatRules.UnreadBaseline(read, created));
|
||||
Assert.Equal(created, ChatRules.UnreadBaseline(null, created));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Typing_signals_are_throttled_per_connection_and_channel()
|
||||
{
|
||||
var time = new FixedTimeProvider(new DateTimeOffset(2026, 10, 12, 8, 0, 0, TimeSpan.Zero));
|
||||
var throttle = new ChatTypingThrottle(time);
|
||||
var channel = Guid.NewGuid();
|
||||
|
||||
Assert.True(throttle.TryAcquire("c1", channel));
|
||||
Assert.False(throttle.TryAcquire("c1", channel));
|
||||
Assert.True(throttle.TryAcquire("c2", channel)); // andere Verbindung
|
||||
Assert.True(throttle.TryAcquire("c1", Guid.NewGuid())); // anderer Kanal
|
||||
|
||||
time.Advance(TimeSpan.FromSeconds(1));
|
||||
Assert.False(throttle.TryAcquire("c1", channel));
|
||||
time.Advance(TimeSpan.FromSeconds(1));
|
||||
Assert.True(throttle.TryAcquire("c1", channel));
|
||||
|
||||
throttle.Release("c1");
|
||||
Assert.True(throttle.TryAcquire("c1", channel)); // nach dem Trennen frei
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chat_events_have_distinct_names()
|
||||
{
|
||||
string[] names = [ChatEvents.MessageReceived, ChatEvents.MessageRemoved, ChatEvents.ReactionsChanged, ChatEvents.ReadChanged, ChatEvents.Typing];
|
||||
|
||||
Assert.Equal(names.Length, names.Distinct().Count());
|
||||
Assert.All(names, n => Assert.StartsWith("Chat", n));
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = now;
|
||||
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using FamilyPlanner.Shared.Chat;
|
||||
using FamilyPlanner.Web.Client.Components.Chat;
|
||||
|
||||
namespace FamilyPlanner.Web.Tests.Chat;
|
||||
|
||||
public sealed class ChatFormatTests
|
||||
{
|
||||
private static readonly DateOnly Today = new(2026, 10, 12);
|
||||
|
||||
[Fact]
|
||||
public void Time_and_day_are_shown_in_the_family_time_zone()
|
||||
{
|
||||
// 22:30 UTC am 12.10. ist in Berlin (MESZ) bereits 00:30 am 13.10.
|
||||
var late = new DateTimeOffset(2026, 10, 12, 22, 30, 0, TimeSpan.Zero);
|
||||
|
||||
Assert.Equal("00:30", ChatFormat.Time(late));
|
||||
Assert.Equal(new DateOnly(2026, 10, 13), ChatFormat.DayOf(late));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "Heute")]
|
||||
[InlineData(-1, "Gestern")]
|
||||
[InlineData(-3, "Fr, 09.10.")]
|
||||
public void Day_separators_are_relative(int offset, string expected) =>
|
||||
Assert.Equal(expected, ChatFormat.Day(Today.AddDays(offset), Today));
|
||||
|
||||
[Fact]
|
||||
public void Day_separators_include_the_year_for_other_years() =>
|
||||
Assert.Equal("30.12.2025", ChatFormat.Day(new DateOnly(2025, 12, 30), Today));
|
||||
|
||||
[Fact]
|
||||
public void Preview_prefixes_the_sender_and_describes_images()
|
||||
{
|
||||
var me = Guid.NewGuid();
|
||||
var other = Guid.NewGuid();
|
||||
var at = DateTimeOffset.UtcNow;
|
||||
|
||||
Assert.Equal("Noch keine Nachrichten", ChatFormat.Preview(null, me, null));
|
||||
Assert.Equal("Du: Hallo", ChatFormat.Preview(new ChatPreviewDto(me, "Hallo", false, at), me, "Ich"));
|
||||
Assert.Equal("Mia: Zeile 1 Zeile 2", ChatFormat.Preview(new ChatPreviewDto(other, "Zeile 1\nZeile 2", false, at), me, "Mia"));
|
||||
Assert.Equal("Mia: 📷 Foto", ChatFormat.Preview(new ChatPreviewDto(other, null, true, at), me, "Mia"));
|
||||
Assert.Equal("Du: 📷 Urlaub", ChatFormat.Preview(new ChatPreviewDto(me, "Urlaub", true, at), me, null));
|
||||
Assert.Equal("Hi", ChatFormat.Preview(new ChatPreviewDto(other, "Hi", false, at), me, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Typing_text_names_up_to_two_people()
|
||||
{
|
||||
Assert.Equal(string.Empty, ChatFormat.Typing([]));
|
||||
Assert.Equal("Mia schreibt …", ChatFormat.Typing(["Mia"]));
|
||||
Assert.Equal("Mia und Ben schreiben …", ChatFormat.Typing(["Mia", "Ben"]));
|
||||
Assert.Equal("3 Personen schreiben …", ChatFormat.Typing(["Mia", "Ben", "Oma"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unread_label_uses_singular_for_one()
|
||||
{
|
||||
Assert.Equal("1 ungelesene Nachricht", ChatFormat.UnreadLabel(1));
|
||||
Assert.Equal("4 ungelesene Nachrichten", ChatFormat.UnreadLabel(4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@* Testhülle: MudBlazor-Provider + Chat-Seite (Dialoge, Menüs und Hinweise brauchen sie). *@
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<FamilyPlanner.Web.Client.Pages.Chat ChannelId="ChannelId" />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid? ChannelId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
using FamilyPlanner.Domain.Chat;
|
||||
using FamilyPlanner.Shared.Auth;
|
||||
using FamilyPlanner.Shared.Chat;
|
||||
using FamilyPlanner.Web.Client.Auth;
|
||||
using FamilyPlanner.Web.Client.Services;
|
||||
using FamilyPlanner.Web.Tests.Shopping;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MudBlazor.Services;
|
||||
|
||||
namespace FamilyPlanner.Web.Tests.Chat;
|
||||
|
||||
public sealed class ChatPageTests : BunitContext
|
||||
{
|
||||
private readonly FakeChatApi _api = new();
|
||||
private readonly FakeLiveConnection _live = new();
|
||||
private readonly MutableTimeProvider _time = new(FakeChatApi.Now);
|
||||
|
||||
public ChatPageTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
JSInterop.Setup<bool>("fpChat.isVisible", _ => true).SetResult(true);
|
||||
Services.AddMudServices();
|
||||
Services.AddSingleton<IChatApi>(_api);
|
||||
Services.AddSingleton<ILiveConnection>(_live);
|
||||
Services.AddSingleton<IAuthService, NoopAuthService>();
|
||||
Services.AddSingleton<ApiAuthenticationStateProvider>();
|
||||
Services.AddSingleton<TimeProvider>(_time);
|
||||
Services.AddSingleton<ChatStore>();
|
||||
}
|
||||
|
||||
private void SignInAs(string role)
|
||||
{
|
||||
var permissions = PermissionMatrix.Effective([role]);
|
||||
Services.GetRequiredService<ApiAuthenticationStateProvider>().SetUser(new CurrentUserDto(
|
||||
FakeChatApi.Me, "ich", "Ich", null, "#1E88E5", [role], false, null, null, permissions));
|
||||
}
|
||||
|
||||
private IRenderedComponent<ChatPageHost> Open(Guid? channelId = null) =>
|
||||
Render<ChatPageHost>(p => p.Add(x => x.ChannelId, channelId));
|
||||
|
||||
private static List<string> Texts(IRenderedComponent<ChatPageHost> cut) => cut.FindAll(".fp-chat-msg .fp-chat-text").Select(t => t.TextContent).ToList();
|
||||
|
||||
[Fact]
|
||||
public void Channel_list_shows_unread_counts_previews_and_marks_the_open_one()
|
||||
{
|
||||
var direct = _api.AddDirect(FakeChatApi.Mia, "Mia", unread: 2);
|
||||
_api.Seed(FakeChatApi.FamilyId, FakeChatApi.Ben, "Wer holt Brot?");
|
||||
_api.Seed(direct.Id, FakeChatApi.Mia, "Darf ich zu Lena?");
|
||||
_api.Seed(direct.Id, FakeChatApi.Me, "Bis 18 Uhr");
|
||||
SignInAs(Roles.Family);
|
||||
|
||||
var cut = Open();
|
||||
|
||||
var channels = cut.FindAll(".fp-chat-channel");
|
||||
Assert.Equal(2, channels.Count);
|
||||
Assert.Equal("Familienchat", channels[0].QuerySelector(".fp-chat-channel-title")!.TextContent);
|
||||
Assert.Equal("Ben: Wer holt Brot?", channels[0].QuerySelector(".fp-chat-channel-preview")!.TextContent);
|
||||
Assert.Equal("Du: Bis 18 Uhr", channels[1].QuerySelector(".fp-chat-channel-preview")!.TextContent);
|
||||
Assert.Equal("2", channels[1].QuerySelector(".fp-chat-unread")!.TextContent.Trim());
|
||||
Assert.Contains("2 ungelesene Nachrichten", channels[1].GetAttribute("aria-label"));
|
||||
Assert.Null(channels[0].QuerySelector(".fp-chat-unread"));
|
||||
Assert.Contains("show-list", cut.Find(".fp-chat").ClassList);
|
||||
Assert.Contains("Wähle eine Unterhaltung aus.", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Conversation_shows_day_separators_sender_names_and_marks_the_conversation_read()
|
||||
{
|
||||
_api.SetUnread(FakeChatApi.FamilyId, 2);
|
||||
_api.Seed(FakeChatApi.FamilyId, FakeChatApi.Mia, "Gestern Abend", FakeChatApi.Now.AddDays(-1));
|
||||
_api.Seed(FakeChatApi.FamilyId, FakeChatApi.Ben, "Guten Morgen", FakeChatApi.Now.AddHours(-1));
|
||||
var last = _api.Seed(FakeChatApi.FamilyId, FakeChatApi.Me, "Moin zusammen", FakeChatApi.Now.AddMinutes(-30));
|
||||
SignInAs(Roles.Family);
|
||||
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal(["Gestern Abend", "Guten Morgen", "Moin zusammen"], Texts(cut)));
|
||||
Assert.Equal(["Gestern", "Heute"], cut.FindAll(".fp-chat-day").Select(d => d.TextContent.Trim()));
|
||||
Assert.Equal(["Mia", "Ben"], cut.FindAll(".fp-chat-sender").Select(s => s.TextContent));
|
||||
Assert.Contains("show-conversation", cut.Find(".fp-chat").ClassList);
|
||||
Assert.Equal("Familienchat", cut.Find(".fp-chat-header h2").TextContent);
|
||||
Assert.Equal(["is-other", "is-other", "is-own"], cut.FindAll(".fp-chat-msg").Select(m => m.ClassList.Single(c => c.StartsWith("is-"))));
|
||||
|
||||
// Zeiten in Europe/Berlin (MESZ = UTC+2): 08:00Z - 30 min = 07:30Z = 09:30
|
||||
Assert.Equal("09:30", cut.FindAll(".fp-chat-time")[2].TextContent);
|
||||
|
||||
// Sichtbar + ungelesen → bis zur neuesten Nachricht als gelesen markiert; der Zähler verschwindet.
|
||||
cut.WaitForAssertion(() => Assert.Equal([(FakeChatApi.FamilyId, (Guid?)last.Id)], _api.ReadCalls));
|
||||
Assert.Empty(cut.FindAll(".fp-chat-unread"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_receipts_show_ticks_in_direct_chats_and_counts_in_the_family_chat()
|
||||
{
|
||||
var direct = _api.AddDirect(FakeChatApi.Mia, "Mia");
|
||||
var first = _api.Seed(direct.Id, FakeChatApi.Me, "Gelesen", FakeChatApi.Now.AddMinutes(-20));
|
||||
_api.Seed(direct.Id, FakeChatApi.Me, "Noch nicht", FakeChatApi.Now.AddMinutes(-5));
|
||||
_api.ReadStates[direct.Id] = [new(FakeChatApi.Mia, first.CreatedAt.AddMinutes(1))];
|
||||
_api.Seed(FakeChatApi.FamilyId, FakeChatApi.Me, "Hallo alle", FakeChatApi.Now.AddMinutes(-10));
|
||||
_api.ReadStates[FakeChatApi.FamilyId] = [new(FakeChatApi.Mia, FakeChatApi.Now), new(FakeChatApi.Ben, FakeChatApi.Now)];
|
||||
SignInAs(Roles.Family);
|
||||
|
||||
var cut = Open(direct.Id);
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-chat-msg").Count));
|
||||
var messages = cut.FindAll(".fp-chat-msg");
|
||||
Assert.NotNull(messages[0].QuerySelector(".fp-chat-read"));
|
||||
Assert.Equal("Gelesen", messages[0].QuerySelector(".fp-chat-read")!.GetAttribute("aria-label"));
|
||||
Assert.Null(messages[1].QuerySelector(".fp-chat-read"));
|
||||
Assert.NotNull(messages[1].QuerySelector(".fp-chat-sent"));
|
||||
|
||||
cut.Render(p => p.Add(x => x.ChannelId, FakeChatApi.FamilyId));
|
||||
cut.WaitForAssertion(() => Assert.Equal("Gelesen von 2", cut.Find(".fp-chat-read").GetAttribute("aria-label")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Older_messages_are_loaded_page_by_page_until_the_history_is_complete()
|
||||
{
|
||||
for (var i = 1; i <= 40; i++)
|
||||
{
|
||||
_api.Seed(FakeChatApi.FamilyId, FakeChatApi.Ben, $"Nachricht {i:00}");
|
||||
}
|
||||
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal(ChatRules.DefaultPageSize, cut.FindAll(".fp-chat-msg").Count));
|
||||
Assert.Equal("Nachricht 11", Texts(cut)[0]);
|
||||
cut.Find(".fp-chat-older").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal(40, cut.FindAll(".fp-chat-msg").Count));
|
||||
Assert.Equal(["Nachricht 01", "Nachricht 02"], Texts(cut).Take(2));
|
||||
Assert.Equal("Nachricht 40", Texts(cut)[^1]);
|
||||
Assert.Empty(cut.FindAll(".fp-chat-older"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sending_appends_the_message_clears_the_input_and_keeps_the_draft_on_errors()
|
||||
{
|
||||
SignInAs(Roles.Child);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForElement("form.fp-chat-composer");
|
||||
Assert.True(cut.Find("button.fp-chat-send").HasAttribute("disabled"));
|
||||
|
||||
cut.Find("#chat-input").Input(" Bin gleich da ");
|
||||
Assert.False(cut.Find("button.fp-chat-send").HasAttribute("disabled"));
|
||||
cut.Find("form.fp-chat-composer").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal(["Bin gleich da"], Texts(cut)));
|
||||
Assert.Equal((FakeChatApi.FamilyId, "Bin gleich da"), _api.Sent.Single());
|
||||
Assert.Equal(string.Empty, cut.Find("#chat-input").GetAttribute("value") ?? string.Empty);
|
||||
|
||||
_api.SendError = "Diese Person ist nicht mehr erreichbar.";
|
||||
cut.Find("#chat-input").Input("Zweiter Versuch");
|
||||
cut.Find("form.fp-chat-composer").Submit();
|
||||
cut.WaitForAssertion(() => Assert.Contains("nicht mehr erreichbar", cut.Markup));
|
||||
Assert.Equal("Zweiter Versuch", cut.Find("#chat-input").GetAttribute("value"));
|
||||
Assert.Single(Texts(cut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Images_are_uploaded_with_the_draft_as_caption_and_rejected_when_too_large()
|
||||
{
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForElement("form.fp-chat-composer");
|
||||
|
||||
cut.Find("#chat-input").Input("Schau mal");
|
||||
cut.FindComponent<Microsoft.AspNetCore.Components.Forms.InputFile>().UploadFiles(
|
||||
InputFileContent.CreateFromBinary([1, 2, 3], "urlaub.png", null, "image/png"));
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-chat-image")));
|
||||
Assert.Equal(("urlaub.png", "image/png", "Schau mal"), _api.Images.Single());
|
||||
Assert.StartsWith("/api/chat/attachments/", cut.Find(".fp-chat-image").GetAttribute("src"));
|
||||
Assert.Equal("Schau mal", cut.Find(".fp-chat-image").GetAttribute("alt"));
|
||||
Assert.Equal("_blank", cut.Find(".fp-chat-image-link").GetAttribute("target"));
|
||||
Assert.Contains("noopener", cut.Find(".fp-chat-image-link").GetAttribute("rel"));
|
||||
|
||||
cut.FindComponent<Microsoft.AspNetCore.Components.Forms.InputFile>().UploadFiles(
|
||||
InputFileContent.CreateFromBinary(new byte[ChatRules.MaxImageBytes + 1], "riesig.png", null, "image/png"));
|
||||
cut.WaitForAssertion(() => Assert.Contains("zu groß", cut.Markup));
|
||||
Assert.Single(_api.Images);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reactions_toggle_and_show_counts()
|
||||
{
|
||||
var message = _api.Seed(FakeChatApi.FamilyId, FakeChatApi.Ben, "Pizza heute?");
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForElement(".fp-chat-msg");
|
||||
|
||||
cut.Find(".fp-chat-react-menu button").Click();
|
||||
cut.WaitForElement(".fp-chat-emoji-picker");
|
||||
Assert.Equal(ChatRules.Reactions, cut.FindAll(".fp-chat-emoji").Select(e => e.TextContent));
|
||||
cut.FindAll(".fp-chat-emoji")[0].Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("👍1", cut.Find(".fp-chat-reaction").TextContent.Replace(" ", string.Empty)));
|
||||
Assert.Equal("true", cut.Find(".fp-chat-reaction").GetAttribute("aria-pressed"));
|
||||
|
||||
cut.Find(".fp-chat-reaction").Click(); // zweiter Klick entfernt die eigene Reaktion
|
||||
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-chat-reaction")));
|
||||
Assert.Empty(_api.Messages[FakeChatApi.FamilyId].Single(m => m.Id == message.Id).Reactions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_buttons_follow_the_rules_and_ask_for_confirmation()
|
||||
{
|
||||
var own = _api.Seed(FakeChatApi.FamilyId, FakeChatApi.Me, "Eigene");
|
||||
_api.Seed(FakeChatApi.FamilyId, FakeChatApi.Ben, "Fremde");
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-chat-msg").Count));
|
||||
|
||||
// Familie: nur eigene Nachrichten
|
||||
Assert.Single(cut.FindAll(".fp-chat-delete"));
|
||||
cut.Find(".fp-chat-delete").Click();
|
||||
cut.WaitForElement(".mud-message-box");
|
||||
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Abbrechen")).Click();
|
||||
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".mud-message-box")));
|
||||
Assert.Empty(_api.Deleted);
|
||||
|
||||
cut.Find(".fp-chat-delete").Click();
|
||||
cut.WaitForElement(".mud-message-box");
|
||||
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Löschen")).Click();
|
||||
cut.WaitForAssertion(() => Assert.Equal(["Fremde"], Texts(cut)));
|
||||
Assert.Equal([own.Id], _api.Deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Admins_may_delete_family_chat_messages_of_others_but_not_in_direct_chats()
|
||||
{
|
||||
_api.Seed(FakeChatApi.FamilyId, FakeChatApi.Mia, "Von Mia");
|
||||
var direct = _api.AddDirect(FakeChatApi.Mia, "Mia");
|
||||
_api.Seed(direct.Id, FakeChatApi.Mia, "Privat");
|
||||
SignInAs(Roles.Admin);
|
||||
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-chat-delete")));
|
||||
|
||||
cut.Render(p => p.Add(x => x.ChannelId, direct.Id));
|
||||
cut.WaitForAssertion(() => Assert.Contains("Privat", cut.Markup));
|
||||
Assert.Empty(cut.FindAll(".fp-chat-delete"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Live_message_in_the_open_conversation_is_appended_and_marked_read_only_while_visible()
|
||||
{
|
||||
_api.Seed(FakeChatApi.FamilyId, FakeChatApi.Ben, "Erste");
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForAssertion(() => Assert.Single(Texts(cut)));
|
||||
var reads = _api.ReadCalls.Count;
|
||||
|
||||
var incoming = Incoming(FakeChatApi.FamilyId, FakeChatApi.Ben, "Zweite");
|
||||
cut.InvokeAsync(() => _live.RaiseChatMessage(incoming));
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal(["Erste", "Zweite"], Texts(cut)));
|
||||
cut.WaitForAssertion(() => Assert.Equal(reads + 1, _api.ReadCalls.Count));
|
||||
Assert.Equal(incoming.Id, _api.ReadCalls[^1].UpTo);
|
||||
|
||||
// Doppelt zugestellt (Antwort + Ereignis): keine zweite Anzeige.
|
||||
cut.InvokeAsync(() => _live.RaiseChatMessage(incoming));
|
||||
Assert.Equal(["Erste", "Zweite"], Texts(cut));
|
||||
|
||||
// Tab im Hintergrund: Nachricht erscheint, bleibt aber ungelesen, bis der Tab wieder sichtbar wird.
|
||||
var store = Services.GetRequiredService<ChatStore>();
|
||||
store.IsConversationVisible = false;
|
||||
var hidden = Incoming(FakeChatApi.FamilyId, FakeChatApi.Ben, "Dritte");
|
||||
cut.InvokeAsync(() => _live.RaiseChatMessage(hidden));
|
||||
cut.WaitForAssertion(() => Assert.Equal(["Erste", "Zweite", "Dritte"], Texts(cut)));
|
||||
Assert.Equal(reads + 1, _api.ReadCalls.Count);
|
||||
Assert.Equal(1, store.TotalUnread);
|
||||
|
||||
store.IsConversationVisible = true;
|
||||
cut.WaitForAssertion(() => Assert.Equal(reads + 2, _api.ReadCalls.Count));
|
||||
Assert.Equal(0, store.TotalUnread);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Live_message_in_another_channel_only_raises_the_unread_counter()
|
||||
{
|
||||
var direct = _api.AddDirect(FakeChatApi.Mia, "Mia");
|
||||
_api.Seed(direct.Id, FakeChatApi.Mia, "Hallo");
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForElement(".fp-chat-channel");
|
||||
var store = Services.GetRequiredService<ChatStore>();
|
||||
|
||||
cut.InvokeAsync(() => _live.RaiseChatMessage(Incoming(direct.Id, FakeChatApi.Mia, "Bist du da?")));
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("1", cut.Find($"[data-channel-id='{direct.Id}'] .fp-chat-unread").TextContent.Trim()));
|
||||
Assert.Equal(1, store.TotalUnread);
|
||||
Assert.Empty(cut.FindAll(".fp-chat-msg"));
|
||||
Assert.Contains("Mia: Bist du da?", cut.Find($"[data-channel-id='{direct.Id}'] .fp-chat-channel-preview").TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Own_messages_from_other_devices_do_not_count_as_unread()
|
||||
{
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForElement(".fp-chat-channel");
|
||||
var store = Services.GetRequiredService<ChatStore>();
|
||||
store.IsConversationVisible = false;
|
||||
|
||||
cut.InvokeAsync(() => _live.RaiseChatMessage(Incoming(FakeChatApi.FamilyId, FakeChatApi.Me, "Vom Handy")));
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal(["Vom Handy"], Texts(cut)));
|
||||
Assert.Equal(0, store.TotalUnread);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remote_removal_and_reactions_update_the_open_conversation()
|
||||
{
|
||||
var one = _api.Seed(FakeChatApi.FamilyId, FakeChatApi.Ben, "Bleibt");
|
||||
var two = _api.Seed(FakeChatApi.FamilyId, FakeChatApi.Ben, "Wird gelöscht");
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForAssertion(() => Assert.Equal(2, Texts(cut).Count));
|
||||
|
||||
cut.InvokeAsync(() => _live.RaiseChatReactions(new ChatReactionsChangedDto(FakeChatApi.FamilyId, one.Id, [new("🎉", [FakeChatApi.Mia, FakeChatApi.Ben])])));
|
||||
cut.WaitForAssertion(() => Assert.Equal("🎉2", cut.Find(".fp-chat-reaction").TextContent.Replace(" ", string.Empty)));
|
||||
Assert.Equal("false", cut.Find(".fp-chat-reaction").GetAttribute("aria-pressed"));
|
||||
|
||||
_api.Messages[FakeChatApi.FamilyId].RemoveAll(m => m.Id == two.Id);
|
||||
cut.InvokeAsync(() => _live.RaiseChatRemoved(FakeChatApi.FamilyId, two.Id));
|
||||
cut.WaitForAssertion(() => Assert.Equal(["Bleibt"], Texts(cut)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Typing_indicator_names_people_and_expires()
|
||||
{
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForElement(".fp-chat-typing");
|
||||
var store = Services.GetRequiredService<ChatStore>();
|
||||
|
||||
cut.InvokeAsync(() => _live.RaiseChatTyping(FakeChatApi.FamilyId, FakeChatApi.Mia));
|
||||
cut.WaitForAssertion(() => Assert.Equal("Mia schreibt …", cut.Find(".fp-chat-typing").TextContent));
|
||||
|
||||
cut.InvokeAsync(() => _live.RaiseChatTyping(FakeChatApi.FamilyId, FakeChatApi.Ben));
|
||||
cut.WaitForAssertion(() => Assert.Equal("Mia und Ben schreiben …", cut.Find(".fp-chat-typing").TextContent));
|
||||
|
||||
// Eigene Signale (anderes Gerät) und andere Kanäle zeigen nichts an.
|
||||
cut.InvokeAsync(() => _live.RaiseChatTyping(FakeChatApi.FamilyId, FakeChatApi.Me));
|
||||
cut.InvokeAsync(() => _live.RaiseChatTyping(Guid.NewGuid(), FakeChatApi.Gast));
|
||||
Assert.Equal("Mia und Ben schreiben …", cut.Find(".fp-chat-typing").TextContent);
|
||||
|
||||
_time.Advance(ChatStore.TypingLifetime + TimeSpan.FromSeconds(1));
|
||||
cut.InvokeAsync(() => store.PruneTyping());
|
||||
cut.WaitForAssertion(() => Assert.Equal(string.Empty, cut.Find(".fp-chat-typing").TextContent));
|
||||
|
||||
// Eine Nachricht beendet das „tippt …“ sofort.
|
||||
cut.InvokeAsync(() => _live.RaiseChatTyping(FakeChatApi.FamilyId, FakeChatApi.Mia));
|
||||
cut.WaitForAssertion(() => Assert.Equal("Mia schreibt …", cut.Find(".fp-chat-typing").TextContent));
|
||||
cut.InvokeAsync(() => _live.RaiseChatMessage(Incoming(FakeChatApi.FamilyId, FakeChatApi.Mia, "fertig")));
|
||||
cut.WaitForAssertion(() => Assert.Equal(string.Empty, cut.Find(".fp-chat-typing").TextContent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Typing_is_reported_at_most_every_three_seconds_and_never_for_empty_input()
|
||||
{
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForElement("#chat-input");
|
||||
|
||||
cut.Find("#chat-input").Input(" ");
|
||||
Assert.Empty(_live.TypingSent);
|
||||
|
||||
cut.Find("#chat-input").Input("H");
|
||||
cut.Find("#chat-input").Input("Ha");
|
||||
cut.Find("#chat-input").Input("Hal");
|
||||
Assert.Equal([FakeChatApi.FamilyId], _live.TypingSent);
|
||||
|
||||
_time.Advance(TimeSpan.FromSeconds(3));
|
||||
cut.Find("#chat-input").Input("Hall");
|
||||
Assert.Equal(2, _live.TypingSent.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_state_of_the_partner_updates_ticks_live()
|
||||
{
|
||||
var direct = _api.AddDirect(FakeChatApi.Mia, "Mia");
|
||||
var mine = _api.Seed(direct.Id, FakeChatApi.Me, "Schon gesehen?");
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(direct.Id);
|
||||
cut.WaitForElement(".fp-chat-sent");
|
||||
|
||||
cut.InvokeAsync(() => _live.RaiseChatRead(direct.Id, FakeChatApi.Mia, mine.CreatedAt.AddSeconds(5)));
|
||||
|
||||
cut.WaitForAssertion(() => Assert.NotNull(cut.Find(".fp-chat-read")));
|
||||
Assert.Empty(cut.FindAll(".fp-chat-sent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guests_without_chat_access_see_a_hint_and_no_new_conversation_button()
|
||||
{
|
||||
SignInAs(Roles.Guest);
|
||||
_api.Channels.Clear();
|
||||
|
||||
var cut = Open();
|
||||
|
||||
Assert.Contains("nicht freigeschaltet", cut.Find(".fp-chat-empty").TextContent);
|
||||
Assert.Empty(cut.FindAll(".fp-chat-new"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guests_with_the_family_chat_exception_may_write_but_not_start_direct_chats()
|
||||
{
|
||||
Services.GetRequiredService<ApiAuthenticationStateProvider>().SetUser(new CurrentUserDto(
|
||||
FakeChatApi.Me, "gast", "Gast", null, "#8E24AA", [Roles.Guest], false, null, null,
|
||||
PermissionMatrix.Effective([Roles.Guest], new PermissionOptions(GuestChatEnabled: true))));
|
||||
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
|
||||
cut.WaitForElement("form.fp-chat-composer");
|
||||
Assert.Empty(cut.FindAll(".fp-chat-new"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unreachable_partner_leaves_the_history_readable_but_hides_the_composer()
|
||||
{
|
||||
var direct = _api.AddDirect(FakeChatApi.Mia, "Mia", canSend: false);
|
||||
_api.Seed(direct.Id, FakeChatApi.Mia, "Tschüss");
|
||||
SignInAs(Roles.Family);
|
||||
|
||||
var cut = Open(direct.Id);
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal(["Tschüss"], Texts(cut)));
|
||||
Assert.Empty(cut.FindAll("form.fp-chat-composer"));
|
||||
Assert.Contains("nicht mehr erreichbar", cut.Find(".fp-chat-cannot-send").TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_conversation_offers_a_way_back()
|
||||
{
|
||||
SignInAs(Roles.Family);
|
||||
|
||||
var cut = Open(Guid.NewGuid());
|
||||
|
||||
cut.WaitForElement(".fp-chat-missing");
|
||||
Assert.Empty(cut.FindAll("form.fp-chat-composer"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_conversation_dialog_lists_only_reachable_family_members_and_opens_the_chat()
|
||||
{
|
||||
SignInAs(Roles.Child);
|
||||
var cut = Open();
|
||||
cut.WaitForElement(".fp-chat-new");
|
||||
|
||||
cut.Find(".fp-chat-new").Click();
|
||||
cut.WaitForElement(".fp-chat-candidates");
|
||||
|
||||
Assert.Equal(["Ben", "Mia"], cut.FindAll(".fp-chat-candidate").Select(c => c.QuerySelector("span:not(.mud-avatar):not(.mud-avatar-content)")!.TextContent));
|
||||
cut.FindAll(".fp-chat-candidate").First(c => c.TextContent.Contains("Mia")).Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.EndsWith($"chat/{FakeChatApi.MiaDirectId}", Services.GetRequiredService<NavigationManager>().Uri));
|
||||
Assert.Contains(_api.Channels, c => c.PartnerId == FakeChatApi.Mia);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offline_shows_a_hint_and_reconnect_reloads_missed_messages()
|
||||
{
|
||||
_api.Seed(FakeChatApi.FamilyId, FakeChatApi.Ben, "Vorher");
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForAssertion(() => Assert.Equal(["Vorher"], Texts(cut)));
|
||||
|
||||
_api.Seed(FakeChatApi.FamilyId, FakeChatApi.Ben, "Während der Funkstille");
|
||||
cut.InvokeAsync(() => _live.RaiseReconnected());
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal(["Vorher", "Während der Funkstille"], Texts(cut)));
|
||||
|
||||
_api.Offline = true;
|
||||
cut.InvokeAsync(() => _live.RaiseReconnected());
|
||||
cut.WaitForAssertion(() => Assert.Contains("funktioniert nur online", cut.Find(".fp-chat-offline").TextContent));
|
||||
Assert.Equal(2, Texts(cut).Count); // der letzte Stand bleibt sichtbar
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Leaving_the_page_closes_the_conversation_and_stops_marking_read()
|
||||
{
|
||||
_api.Seed(FakeChatApi.FamilyId, FakeChatApi.Ben, "Hallo");
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Open(FakeChatApi.FamilyId);
|
||||
cut.WaitForAssertion(() => Assert.Single(Texts(cut)));
|
||||
var store = Services.GetRequiredService<ChatStore>();
|
||||
Assert.NotNull(store.SelectedChannelId);
|
||||
var reads = _api.ReadCalls.Count;
|
||||
|
||||
await DisposeComponentsAsync();
|
||||
|
||||
Assert.Null(store.SelectedChannelId);
|
||||
Assert.False(store.IsConversationVisible);
|
||||
await _live.RaiseChatMessage(Incoming(FakeChatApi.FamilyId, FakeChatApi.Ben, "Zu spät"));
|
||||
Assert.Equal(reads, _api.ReadCalls.Count);
|
||||
Assert.Equal(1, store.TotalUnread); // als ungelesen gezählt, solange die Seite zu ist
|
||||
}
|
||||
|
||||
private static int _incoming;
|
||||
|
||||
private static ChatMessageDto Incoming(Guid channelId, Guid sender, string text) =>
|
||||
new(Guid.NewGuid(), channelId, sender, sender == FakeChatApi.Ben ? "Ben" : sender == FakeChatApi.Mia ? "Mia" : "Ich", text, null,
|
||||
FakeChatApi.Now.AddMinutes(30 + Interlocked.Increment(ref _incoming)), []);
|
||||
|
||||
private sealed class NoopAuthService : IAuthService
|
||||
{
|
||||
public Task<CurrentUserDto?> GetCurrentUserAsync(CancellationToken cancellationToken = default) => Task.FromResult<CurrentUserDto?>(null);
|
||||
|
||||
public Task<AuthResult> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public Task LogoutAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task<AuthResult> ChangePasswordAsync(ChangePasswordRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Chat;
|
||||
using FamilyPlanner.Shared.Admin;
|
||||
using FamilyPlanner.Shared.Chat;
|
||||
using FamilyPlanner.Web.Client.Services;
|
||||
|
||||
namespace FamilyPlanner.Web.Tests.Chat;
|
||||
|
||||
/// <summary>In-Memory-„Server“ des Chats: Kanäle, Verlauf mit Paging, Senden, Reaktionen, Gelesen-Stand.</summary>
|
||||
internal sealed class FakeChatApi : IChatApi
|
||||
{
|
||||
public static readonly Guid Me = Guid.Parse("00000000-0000-0000-0000-0000000000b1");
|
||||
public static readonly Guid Mia = Guid.Parse("00000000-0000-0000-0000-0000000000b2");
|
||||
public static readonly Guid Ben = Guid.Parse("00000000-0000-0000-0000-0000000000b3");
|
||||
public static readonly Guid Gast = Guid.Parse("00000000-0000-0000-0000-0000000000b4");
|
||||
public static readonly Guid FamilyId = Guid.Parse("00000000-0000-0000-0000-0000000000c1");
|
||||
public static readonly Guid MiaDirectId = Guid.Parse("00000000-0000-0000-0000-0000000000c2");
|
||||
public static readonly DateTimeOffset Now = new(2026, 10, 12, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private int _sequence;
|
||||
|
||||
public List<ChatChannelDto> Channels { get; } =
|
||||
[
|
||||
new(FamilyId, ChatChannelKind.Family, "Familienchat", null, true, 0, null, null),
|
||||
];
|
||||
|
||||
public Dictionary<Guid, List<ChatMessageDto>> Messages { get; } = [];
|
||||
|
||||
public Dictionary<Guid, List<ChatReadStateDto>> ReadStates { get; } = [];
|
||||
|
||||
public List<UserDirectoryEntry> Users { get; } =
|
||||
[
|
||||
new(Me, "Ich", "#1E88E5", null, Roles.Family),
|
||||
new(Mia, "Mia", "#E53935", null, Roles.Child),
|
||||
new(Ben, "Ben", "#43A047", null, Roles.Family),
|
||||
new(Gast, "Oma", "#8E24AA", null, Roles.Guest),
|
||||
];
|
||||
|
||||
public bool Offline { get; set; }
|
||||
|
||||
public int ChannelCalls { get; private set; }
|
||||
|
||||
public int MessageCalls { get; private set; }
|
||||
|
||||
public List<(Guid Channel, Guid? UpTo)> ReadCalls { get; } = [];
|
||||
|
||||
public List<Guid> Deleted { get; } = [];
|
||||
|
||||
public List<(Guid Channel, string Text)> Sent { get; } = [];
|
||||
|
||||
public List<(string File, string ContentType, string? Caption)> Images { get; } = [];
|
||||
|
||||
public string? SendError { get; set; }
|
||||
|
||||
public ChatChannelDto AddDirect(Guid partner, string title, int unread = 0, bool canSend = true)
|
||||
{
|
||||
var channel = new ChatChannelDto(partner == Mia ? MiaDirectId : Guid.NewGuid(), ChatChannelKind.Direct, title, partner, canSend, unread, null, null);
|
||||
Channels.Add(channel);
|
||||
return channel;
|
||||
}
|
||||
|
||||
/// <summary>Nachricht eintragen und Kanalvorschau/-zeit nachführen.</summary>
|
||||
public ChatMessageDto Seed(Guid channelId, Guid sender, string text, DateTimeOffset? at = null, string? image = null)
|
||||
{
|
||||
var createdAt = at ?? Now.AddMinutes(-60 + ++_sequence);
|
||||
var dto = new ChatMessageDto(Guid.NewGuid(), channelId, sender, Users.Single(u => u.Id == sender).DisplayName, text, image, createdAt, []);
|
||||
List(channelId).Add(dto);
|
||||
var index = Channels.FindIndex(c => c.Id == channelId);
|
||||
if (index >= 0)
|
||||
{
|
||||
Channels[index] = Channels[index] with
|
||||
{
|
||||
LastMessageAt = createdAt,
|
||||
LastMessage = new ChatPreviewDto(sender, text, image is not null, createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
public void SetUnread(Guid channelId, int unread)
|
||||
{
|
||||
var index = Channels.FindIndex(c => c.Id == channelId);
|
||||
Channels[index] = Channels[index] with { UnreadCount = unread };
|
||||
}
|
||||
|
||||
private List<ChatMessageDto> List(Guid channelId) => Messages.TryGetValue(channelId, out var list) ? list : Messages[channelId] = [];
|
||||
|
||||
public Task<ApiResult<ChatChannelListDto>> GetChannelsAsync(CancellationToken ct = default)
|
||||
{
|
||||
ChannelCalls++;
|
||||
return Result(() => new ChatChannelListDto(Channels.OrderBy(c => c.Kind).ThenByDescending(c => c.LastMessageAt).ToList(), Channels.Sum(c => c.UnreadCount)));
|
||||
}
|
||||
|
||||
public Task<ApiResult<ChatChannelDto>> StartDirectAsync(Guid userId, CancellationToken ct = default) =>
|
||||
Result(() => Channels.FirstOrDefault(c => c.PartnerId == userId) ?? AddDirect(userId, Users.Single(u => u.Id == userId).DisplayName));
|
||||
|
||||
public Task<ApiResult<ChatMessagePageDto>> GetMessagesAsync(Guid channelId, Guid? before, int take, CancellationToken ct = default)
|
||||
{
|
||||
MessageCalls++;
|
||||
return Result(() =>
|
||||
{
|
||||
var all = List(channelId).OrderBy(m => m.CreatedAt).ThenBy(m => m.Id).ToList();
|
||||
if (before is { } id)
|
||||
{
|
||||
all = all.Take(all.FindIndex(m => m.Id == id)).ToList();
|
||||
}
|
||||
|
||||
var page = all.TakeLast(take).ToList();
|
||||
return new ChatMessagePageDto(page, all.Count > page.Count, ReadStates.GetValueOrDefault(channelId) ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ApiResult<ChatMessageDto>> SendAsync(Guid channelId, SendChatMessageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (SendError is not null)
|
||||
{
|
||||
return Task.FromResult(ApiResult<ChatMessageDto>.Fail(new ApiProblem { Status = 409, Detail = SendError }));
|
||||
}
|
||||
|
||||
Sent.Add((channelId, request.Text));
|
||||
return Result(() => Store(channelId, request.Id, request.Text, null));
|
||||
}
|
||||
|
||||
public async Task<ApiResult<ChatMessageDto>> SendImageAsync(Guid channelId, Guid messageId, Stream content, string fileName, string contentType, string? caption, CancellationToken ct = default)
|
||||
{
|
||||
await content.CopyToAsync(Stream.Null, ct);
|
||||
Images.Add((fileName, contentType, caption));
|
||||
return await Result(() => Store(channelId, messageId, caption, $"/api/chat/attachments/{messageId}"));
|
||||
}
|
||||
|
||||
private ChatMessageDto Store(Guid channelId, Guid id, string? text, string? image)
|
||||
{
|
||||
var dto = new ChatMessageDto(id, channelId, Me, "Ich", text, image, Now.AddMinutes(++_sequence), []);
|
||||
List(channelId).Add(dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
public Task<ApiResult<bool>> MarkReadAsync(Guid channelId, Guid? upToMessageId, CancellationToken ct = default)
|
||||
{
|
||||
ReadCalls.Add((channelId, upToMessageId));
|
||||
return Result(() =>
|
||||
{
|
||||
SetUnread(channelId, 0);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ApiResult<bool>> DeleteAsync(Guid messageId, CancellationToken ct = default) =>
|
||||
Result(() =>
|
||||
{
|
||||
Deleted.Add(messageId);
|
||||
foreach (var list in Messages.Values)
|
||||
{
|
||||
list.RemoveAll(m => m.Id == messageId);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
public Task<ApiResult<List<ChatReactionDto>>> SetReactionAsync(Guid messageId, string emoji, bool active, CancellationToken ct = default) =>
|
||||
Result(() =>
|
||||
{
|
||||
foreach (var list in Messages.Values)
|
||||
{
|
||||
var index = list.FindIndex(m => m.Id == messageId);
|
||||
if (index < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var reactions = list[index].Reactions.Select(r => (r.Emoji, Users: r.UserIds.ToList())).ToList();
|
||||
var entry = reactions.FindIndex(r => r.Emoji == emoji);
|
||||
if (active && entry < 0)
|
||||
{
|
||||
reactions.Add((emoji, [Me]));
|
||||
}
|
||||
else if (active && !reactions[entry].Users.Contains(Me))
|
||||
{
|
||||
reactions[entry].Users.Add(Me);
|
||||
}
|
||||
else if (!active && entry >= 0)
|
||||
{
|
||||
reactions[entry].Users.Remove(Me);
|
||||
}
|
||||
|
||||
var result = reactions.Where(r => r.Users.Count > 0).Select(r => new ChatReactionDto(r.Emoji, r.Users)).ToList();
|
||||
list[index] = list[index] with { Reactions = result };
|
||||
return result;
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
public Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default) => Result(() => Users.ToList());
|
||||
|
||||
private Task<ApiResult<T>> Result<T>(Func<T> produce) =>
|
||||
Task.FromResult(Offline ? ApiResult<T>.Fail(ApiProblem.Offline) : ApiResult<T>.Ok(produce()));
|
||||
}
|
||||
|
||||
/// <summary>Zeit, die der Test vorstellen kann (Ablauf von „tippt …“).</summary>
|
||||
internal sealed class MutableTimeProvider(DateTimeOffset start) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = start;
|
||||
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public override TimeZoneInfo LocalTimeZone => FamilyPlanner.Domain.Common.FamilyTime.Berlin;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using FamilyPlanner.Domain.Shopping;
|
||||
using FamilyPlanner.Shared.Chat;
|
||||
using FamilyPlanner.Shared.Meals;
|
||||
using FamilyPlanner.Shared.Shopping;
|
||||
using FamilyPlanner.Shared.Tasks;
|
||||
@@ -180,6 +181,35 @@ internal sealed class FakeLiveConnection : ILiveConnection
|
||||
|
||||
public event Func<Guid, Task>? CalendarChanged;
|
||||
|
||||
public event Func<ChatMessageDto, Task>? ChatMessageReceived;
|
||||
|
||||
public event Func<Guid, Guid, Task>? ChatMessageRemoved;
|
||||
|
||||
public event Func<ChatReactionsChangedDto, Task>? ChatReactionsChanged;
|
||||
|
||||
public event Func<Guid, Guid, DateTimeOffset, Task>? ChatReadChanged;
|
||||
|
||||
public event Func<Guid, Guid, Task>? ChatTyping;
|
||||
|
||||
/// <summary>Kanäle, für die „tippt“ gemeldet wurde.</summary>
|
||||
public List<Guid> TypingSent { get; } = [];
|
||||
|
||||
public Task SendTypingAsync(Guid channelId)
|
||||
{
|
||||
TypingSent.Add(channelId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RaiseChatMessage(ChatMessageDto message) => ChatMessageReceived?.Invoke(message) ?? Task.CompletedTask;
|
||||
|
||||
public Task RaiseChatRemoved(Guid channelId, Guid messageId) => ChatMessageRemoved?.Invoke(channelId, messageId) ?? Task.CompletedTask;
|
||||
|
||||
public Task RaiseChatReactions(ChatReactionsChangedDto change) => ChatReactionsChanged?.Invoke(change) ?? Task.CompletedTask;
|
||||
|
||||
public Task RaiseChatRead(Guid channelId, Guid userId, DateTimeOffset at) => ChatReadChanged?.Invoke(channelId, userId, at) ?? Task.CompletedTask;
|
||||
|
||||
public Task RaiseChatTyping(Guid channelId, Guid userId) => ChatTyping?.Invoke(channelId, userId) ?? Task.CompletedTask;
|
||||
|
||||
public Task RaiseCalendarChanged(Guid calendarId) => CalendarChanged?.Invoke(calendarId) ?? Task.CompletedTask;
|
||||
|
||||
public Task EnsureStartedAsync() => Task.CompletedTask;
|
||||
|
||||
Reference in new issue
Block a user