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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user