test: integration tests for AI chat channel and GIF feature flag

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 19:05:47 +02:00
1 parent 3d22ed8747
commit 5e67075f8a
1 file changed
+364
@@ -0,0 +1,364 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Domain.Chat;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Ai;
using FamilyPlanner.Shared.Chat;
namespace FamilyPlanner.IntegrationTests;
/// <summary>
/// Chat-Erweiterungen end-to-end (ADR-0022): GIF-Suche ohne Schlüssel (aus), KI-Kanal mit Fake-Anbieter – Rechte je Rolle, Privatsphäre,
/// Zweck ohne Rückfall, Tageslimit, Fehlermeldungen ohne Anbietertext, Idempotenz. Der Anbieter und die Einstellungen sind global – daher in der Settings-Collection.
/// </summary>
[Collection(SettingsCollection.Name)]
public sealed class ChatExtrasTests(AppFixture fixture)
{
private const string ProviderName = "Fake-Ollama Chat";
private const string ProviderSecret = "PROVIDER-INTERNAL-TEXT-9876";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static string OllamaReply(string text) =>
JsonSerializer.Serialize(new { message = new { role = "assistant", content = text }, prompt_eval_count = 5, eval_count = 7 });
private static async Task ClearProvidersAsync(HttpClient admin)
{
foreach (var provider in (await admin.GetFromJsonAsync<List<AiProviderDto>>("/api/admin/ai/providers", Ct))!)
{
await admin.DeleteAsync($"/api/admin/ai/providers/{provider.Id}", Ct);
}
}
private static async Task ConfigureAsync(HttpClient admin, string baseUrl, AiPurpose purposes = AiPurpose.ChatAssistant, int limit = 0)
{
await ClearProvidersAsync(admin);
var request = new SaveAiProviderRequest(ProviderName, AiProviderType.Ollama, baseUrl, "fake-chat-model", null, false, 30, true, false, purposes, limit);
var response = await admin.PostAsJsonAsync("/api/admin/ai/providers", request, Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
private static async Task<FamilySettingsDto> SetAiChatAsync(HttpClient admin, bool enabled, bool children = false)
{
var settings = (await admin.GetFromJsonAsync<FamilySettingsDto>("/api/settings", Ct))!;
var response = await admin.PutAsJsonAsync("/api/admin/settings", settings with { AiChatEnabled = enabled, AiChatChildrenAllowed = children }, Ct);
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync(Ct));
return settings;
}
private static async Task<ChatChannelDto> StartAiAsync(HttpClient client)
{
var response = await client.PostAsync("/api/chat/ai", null, Ct);
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<ChatChannelDto>(Ct))!;
}
private static Task<HttpResponseMessage> SendRawAsync(HttpClient client, Guid channelId, string text, Guid? id = null) =>
client.PostAsJsonAsync($"/api/chat/channels/{channelId}/messages", new SendChatMessageRequest(id ?? Guid.NewGuid(), text), Ct);
private static async Task<ChatMessagePageDto> PageAsync(HttpClient client, Guid channelId) =>
(await client.GetFromJsonAsync<ChatMessagePageDto>($"/api/chat/channels/{channelId}/messages", Ct))!;
private static async Task<ChatFeaturesDto> FeaturesAsync(HttpClient client) =>
(await client.GetFromJsonAsync<ChatFeaturesDto>("/api/chat/features", Ct))!;
// ---------- GIF ----------
[Fact]
public async Task Gif_search_is_off_without_an_api_key_and_nothing_can_be_sent_or_fetched()
{
var (_, mom) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _m = mom;
Assert.False((await FeaturesAsync(mom)).GifSearchAvailable);
Assert.Equal(HttpStatusCode.NotFound, (await mom.GetAsync("/api/chat/gifs?q=katze", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await mom.GetAsync("/api/chat/gifs/abc123/preview", Ct)).StatusCode);
var family = (await mom.GetFromJsonAsync<ChatChannelListDto>("/api/chat/channels", Ct))!.Channels.Single(c => c.Kind == ChatChannelKind.Family);
var send = await mom.PostAsJsonAsync($"/api/chat/channels/{family.Id}/gifs", new SendGifRequest(Guid.NewGuid(), "abc123"), Ct);
Assert.Equal(HttpStatusCode.NotFound, send.StatusCode);
}
[Fact]
public async Task Gif_requests_need_a_login()
{
using var anonymous = fixture.CreateApiClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/chat/gifs?q=katze", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/chat/features", Ct)).StatusCode);
}
// ---------- KI-Kanal: Freigabe und Rechte ----------
[Fact]
public async Task Ai_channel_is_off_by_default_needs_a_provider_for_the_purpose_and_never_falls_back_to_the_default_provider()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (_, mom) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _m = mom;
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, OllamaReply("Hallo!"), TimeSpan.Zero);
var original = await SetAiChatAsync(admin, enabled: false);
try
{
await ConfigureAsync(admin, server.BaseUrl, AiPurpose.Default); // nur Standard, kein Chat-Zweck
// Ausgeschaltet: kein Kanal, kein Button
Assert.Equal(HttpStatusCode.Forbidden, (await mom.PostAsync("/api/chat/ai", null, Ct)).StatusCode);
Assert.False((await FeaturesAsync(mom)).AiChatAvailable);
// Eingeschaltet, aber ohne zugeordneten Anbieter: kein Rückfall auf den Standard-Anbieter
await SetAiChatAsync(admin, enabled: true);
Assert.False((await FeaturesAsync(mom)).AiChatAvailable);
Assert.Equal(HttpStatusCode.Conflict, (await mom.PostAsync("/api/chat/ai", null, Ct)).StatusCode);
Assert.Empty(server.Requests);
await ConfigureAsync(admin, server.BaseUrl);
Assert.True((await FeaturesAsync(mom)).AiChatAvailable);
}
finally
{
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
await ClearProvidersAsync(admin);
}
}
[Fact]
public async Task Only_parents_use_the_ai_channel_by_default_children_only_with_the_setting_and_guests_never()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (_, mom) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (_, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
using var _m = mom;
using var _k = kid;
using var _g = guest;
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, OllamaReply("Hallo!"), TimeSpan.Zero);
var original = await SetAiChatAsync(admin, enabled: true, children: false);
try
{
await ConfigureAsync(admin, server.BaseUrl);
Assert.True((await FeaturesAsync(mom)).AiChatAvailable);
Assert.True((await FeaturesAsync(admin)).AiChatAvailable);
Assert.False((await FeaturesAsync(kid)).AiChatAvailable);
Assert.Equal(HttpStatusCode.Forbidden, (await kid.PostAsync("/api/chat/ai", null, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await guest.PostAsync("/api/chat/ai", null, Ct)).StatusCode);
await SetAiChatAsync(admin, enabled: true, children: true);
Assert.True((await FeaturesAsync(kid)).AiChatAvailable);
var channel = await StartAiAsync(kid);
Assert.Equal(ChatChannelKind.Ai, channel.Kind);
Assert.Equal(HttpStatusCode.Forbidden, (await guest.PostAsync("/api/chat/ai", null, Ct)).StatusCode);
// Die Kinder-Freigabe gilt nur für den Chat: die Lern-KI-Funktionen (AiUse) bleiben für Kinder gesperrt.
Assert.Equal(HttpStatusCode.Forbidden, (await kid.GetAsync("/api/ai/status", Ct)).StatusCode);
// Wieder aus: der Kanal verschwindet aus der Liste und ist nicht mehr erreichbar
await SetAiChatAsync(admin, enabled: false);
Assert.DoesNotContain((await kid.GetFromJsonAsync<ChatChannelListDto>("/api/chat/channels", Ct))!.Channels, c => c.Kind == ChatChannelKind.Ai);
Assert.Equal(HttpStatusCode.NotFound, (await kid.GetAsync($"/api/chat/channels/{channel.Id}/messages", Ct)).StatusCode);
}
finally
{
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
await ClearProvidersAsync(admin);
}
}
// ---------- KI-Kanal: Gespräch ----------
[Fact]
public async Task Question_is_saved_and_answered_by_the_ai_with_a_fixed_prompt_context_and_a_log_without_content()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (mom, momClient) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _m = momClient;
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, OllamaReply("Der Mount Everest ist 8849 Meter hoch."), TimeSpan.Zero);
var original = await SetAiChatAsync(admin, enabled: true);
try
{
await ConfigureAsync(admin, server.BaseUrl);
var channel = await StartAiAsync(momClient);
Assert.Equal("KI-Chat", channel.Title);
Assert.Equal(channel.Id, (await StartAiAsync(momClient)).Id); // je Person genau ein Kanal
var question = Guid.NewGuid();
var response = await SendRawAsync(momClient, channel.Id, "Wie hoch ist der Mount Everest?", question);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var page = await PageAsync(momClient, channel.Id);
Assert.Equal(2, page.Messages.Count);
Assert.Equal(mom.Id, page.Messages[0].SenderId);
Assert.Equal(ChatRules.AiSenderId, page.Messages[1].SenderId);
Assert.Equal("KI", page.Messages[1].SenderName);
Assert.Equal("Der Mount Everest ist 8849 Meter hoch.", page.Messages[1].Text);
// Anfrage an den Anbieter: fester System-Prompt, Frage als Nutzer-Nachricht, keine Namen oder Konten
var body = server.Requests.Single().Body ?? string.Empty;
Assert.Contains("Kinder", body);
Assert.Contains("Wie hoch ist der Mount Everest?", body);
Assert.DoesNotContain(mom.UserName, body);
// Erneutes Senden derselben Id: keine zweite KI-Anfrage, keine doppelte Nachricht
var again = await SendRawAsync(momClient, channel.Id, "Wie hoch ist der Mount Everest?", question);
Assert.Equal(HttpStatusCode.OK, again.StatusCode);
Assert.Single(server.Requests);
Assert.Equal(2, (await PageAsync(momClient, channel.Id)).Messages.Count);
// Zweite Frage: kurzer Verlauf (Frage, Antwort, neue Frage) geht mit
await SendRawAsync(momClient, channel.Id, "Und der Zugspitze?");
var second = server.Requests.Last().Body ?? string.Empty;
Assert.Contains("Mount Everest", second);
Assert.Contains("Zugspitze", second);
// Protokoll ohne Inhalt, mit dem neuen Zweck
var usage = (await admin.GetFromJsonAsync<AiUsageOverviewDto>("/api/admin/ai/usage", Ct))!;
var entries = usage.Recent.Where(e => e.Purpose == AiPurpose.ChatAssistant).ToList();
Assert.True(entries.Count(e => e.Success) >= 2);
Assert.DoesNotContain("Everest", JsonSerializer.Serialize(entries));
// Eigene KI-Antworten darf man löschen, die Frage ebenfalls
var delete = await momClient.DeleteAsync($"/api/chat/messages/{page.Messages[1].Id}", Ct);
Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode);
}
finally
{
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
await ClearProvidersAsync(admin);
}
}
[Fact]
public async Task Every_ai_channel_is_private_even_admins_and_other_parents_get_404_and_see_no_channel()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (_, mom) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (_, dad) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _m = mom;
using var _d = dad;
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, OllamaReply("Geheim"), TimeSpan.Zero);
var original = await SetAiChatAsync(admin, enabled: true);
try
{
await ConfigureAsync(admin, server.BaseUrl);
var channel = await StartAiAsync(mom);
await SendRawAsync(mom, channel.Id, "Privat");
foreach (var other in new[] { dad, admin })
{
Assert.Equal(HttpStatusCode.NotFound, (await other.GetAsync($"/api/chat/channels/{channel.Id}/messages", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await SendRawAsync(other, channel.Id, "Mitlesen?")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await other.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/read", new MarkChatReadRequest(), Ct)).StatusCode);
Assert.DoesNotContain((await other.GetFromJsonAsync<ChatChannelListDto>("/api/chat/channels", Ct))!.Channels, c => c.Id == channel.Id);
}
// Jeder hat seinen eigenen Kanal
var dadChannel = await StartAiAsync(dad);
Assert.NotEqual(channel.Id, dadChannel.Id);
Assert.Empty((await PageAsync(dad, dadChannel.Id)).Messages);
// Die Antwort der KI (Nachricht eines Systemabsenders) kann niemand im Familienchat löschen/moderieren
var message = (await PageAsync(mom, channel.Id)).Messages[1];
Assert.Equal(HttpStatusCode.NotFound, (await admin.DeleteAsync($"/api/chat/messages/{message.Id}", Ct)).StatusCode);
}
finally
{
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
await ClearProvidersAsync(admin);
}
}
[Fact]
public async Task Only_text_up_to_the_ai_limit_is_accepted_in_the_ai_channel()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (_, mom) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _m = mom;
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, OllamaReply("ok"), TimeSpan.Zero);
var original = await SetAiChatAsync(admin, enabled: true);
try
{
await ConfigureAsync(admin, server.BaseUrl);
var channel = await StartAiAsync(mom);
Assert.Equal(HttpStatusCode.BadRequest, (await SendRawAsync(mom, channel.Id, new string('a', ChatRules.AiPromptMaxLength + 1))).StatusCode);
Assert.Empty(server.Requests);
using var form = new MultipartFormDataContent { { new ByteArrayContent(Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")), "file", "a.png" } };
Assert.Equal(HttpStatusCode.BadRequest, (await mom.PostAsync($"/api/chat/channels/{channel.Id}/images", form, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await mom.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/gifs", new SendGifRequest(Guid.NewGuid(), "abc123"), Ct)).StatusCode);
Assert.Empty(server.Requests);
}
finally
{
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
await ClearProvidersAsync(admin);
}
}
[Fact]
public async Task Provider_errors_keep_the_question_and_return_a_plain_message_without_provider_text()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (_, mom) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _m = mom;
await using var server = FakeAiServer.Start();
server.Respond = _ => (500, ProviderSecret, TimeSpan.Zero);
var original = await SetAiChatAsync(admin, enabled: true);
try
{
await ConfigureAsync(admin, server.BaseUrl);
var channel = await StartAiAsync(mom);
var response = await SendRawAsync(mom, channel.Id, "Hallo?");
Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode);
var text = await response.Content.ReadAsStringAsync(Ct);
Assert.DoesNotContain(ProviderSecret, text);
Assert.Contains("nicht erreichbar", text);
var page = await PageAsync(mom, channel.Id);
Assert.Single(page.Messages); // die Frage bleibt, es gibt keine Fehler-Antwort im Verlauf
}
finally
{
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
await ClearProvidersAsync(admin);
}
}
[Fact]
public async Task Daily_limit_of_the_provider_applies_to_the_ai_channel()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (_, mom) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _m = mom;
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, OllamaReply("Antwort"), TimeSpan.Zero);
var original = await SetAiChatAsync(admin, enabled: true);
try
{
await ConfigureAsync(admin, server.BaseUrl, limit: 1);
var channel = await StartAiAsync(mom);
Assert.Equal(HttpStatusCode.Created, (await SendRawAsync(mom, channel.Id, "Eins")).StatusCode);
var second = await SendRawAsync(mom, channel.Id, "Zwei");
Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode);
Assert.Contains("Tageslimit", await second.Content.ReadAsStringAsync(Ct));
Assert.Single(server.Requests);
}
finally
{
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
await ClearProvidersAsync(admin);
}
}
}