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:
JannisandClaude Sonnet 5.5 committed 2026-10-10 08:48:07 +02:00
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;