test: unit tests for chat extensions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 18:59:17 +02:00
1 parent 154e400d13
commit 3d22ed8747
2 files changed
+289 -1

No files matched your search

@@ -110,7 +110,7 @@ public sealed class AiRulesTests
[Fact]
public void Purposes_are_distinct_bits_with_names()
{
Assert.Equal(3, AiRules.SelectablePurposes.Count);
Assert.Equal(4, AiRules.SelectablePurposes.Count);
Assert.Equal(AiRules.AllPurposes, AiRules.SelectablePurposes.Aggregate(AiPurpose.None, (a, p) => a | p));
Assert.All(AiRules.SelectablePurposes, p => Assert.True(AiRules.IsSinglePurpose(p)));
Assert.All(AiRules.SelectablePurposes, p => Assert.DoesNotContain("Learning", AiRules.PurposeName(p)));
@@ -0,0 +1,288 @@
using System.Net;
using System.Text;
using FamilyPlanner.Api.Features.Ai;
using FamilyPlanner.Api.Features.Chat;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Domain.Chat;
using FamilyPlanner.Shared.Chat;
using Microsoft.Extensions.Options;
namespace FamilyPlanner.UnitTests.Chat;
/// <summary>Chat-Erweiterungen (ADR-0022): KI-Kanal-Regeln, Prompt, GIF-Prüfung und der Giphy-Adapter.</summary>
public sealed class ChatExtrasTests
{
// ---------- KI-Chat: wer darf ----------
[Theory]
[InlineData(Roles.Admin, true, false, true)]
[InlineData(Roles.Family, true, false, true)]
[InlineData(Roles.Child, true, false, false)]
[InlineData(Roles.Child, true, true, true)]
[InlineData(Roles.Guest, true, true, false)]
[InlineData(Roles.Admin, false, true, false)]
[InlineData(Roles.Family, false, false, false)]
[InlineData(Roles.Child, false, true, false)]
public void Ai_chat_access_needs_the_admin_switch_and_children_only_with_their_setting(string role, bool enabled, bool childrenAllowed, bool expected) =>
Assert.Equal(expected, ChatRules.CanUseAiChat([role], enabled, childrenAllowed));
[Fact]
public void Ai_chat_setting_does_not_widen_the_ai_use_permission()
{
// Die Kinder-Freigabe gilt nur für den Chat: AiUse (Lernen, /api/ai/status) bleibt Eltern vorbehalten.
Assert.Equal(FamilyPlanner.Domain.Authorization.AccessLevel.None, FamilyPlanner.Domain.Authorization.PermissionMatrix.For(FamilyPlanner.Domain.Authorization.Permission.AiUse, [Roles.Child]));
Assert.True(ChatRules.CanUseAiChat([Roles.Child], true, true));
}
[Fact]
public void Ai_channel_key_is_unique_per_person_and_ai_replies_can_be_deleted_in_the_own_channel_only()
{
var a = Guid.NewGuid();
var b = Guid.NewGuid();
Assert.NotEqual(ChatRules.AiKey(a), ChatRules.AiKey(b));
Assert.True(ChatRules.AiKey(a).Length <= ChatChannel.DirectKeyMaxLength);
Assert.True(ChatRules.CanDelete(ChatRules.AiSenderId, a, false, ChatChannelKind.Ai));
Assert.False(ChatRules.CanDelete(ChatRules.AiSenderId, a, false, ChatChannelKind.Family));
Assert.False(ChatRules.CanDelete(b, a, true, ChatChannelKind.Ai)); // Admin moderiert fremde KI-Kanäle nie
}
// ---------- KI-Zweck ----------
[Fact]
public void Chat_assistant_is_a_selectable_purpose_without_fallback_to_the_default_provider()
{
Assert.Contains(AiPurpose.ChatAssistant, AiRules.SelectablePurposes);
Assert.True(AiRules.IsValidPurposes(AiPurpose.ChatAssistant | AiPurpose.Default));
Assert.False(AiRules.FallsBackToDefault(AiPurpose.ChatAssistant));
Assert.False(AiRules.FallsBackToDefault(AiPurpose.LearningImageGrading));
Assert.True(AiRules.FallsBackToDefault(AiPurpose.LearningGeneration));
Assert.Equal("KI-Chat", AiRules.PurposeName(AiPurpose.ChatAssistant));
}
// ---------- Prompt ----------
[Fact]
public void Prompt_is_a_fixed_child_friendly_german_text_without_family_data()
{
Assert.Contains("Deutsch", ChatAssistantPrompt.System);
Assert.Contains("Kinder", ChatAssistantPrompt.System);
Assert.Contains("persönlichen Daten", ChatAssistantPrompt.System);
}
[Fact]
public void Context_keeps_only_the_latest_text_messages_in_order_and_maps_roles()
{
var user = Guid.NewGuid();
var newestFirst = new List<(Guid, string?)>();
for (var i = 10; i >= 1; i--)
{
newestFirst.Add((i % 2 == 0 ? ChatRules.AiSenderId : user, $"m{i}"));
}
var messages = ChatAssistantPrompt.BuildMessages(newestFirst);
Assert.True(messages.Count <= ChatRules.AiContextMessages);
Assert.Equal("user", messages[0].Role);
Assert.Equal("m10", messages[^1].Text); // die letzten sechs sind m5..m10
}
[Fact]
public void Context_merges_consecutive_same_role_messages_clips_long_text_and_skips_images()
{
var user = Guid.NewGuid();
var longText = new string('x', ChatRules.AiPromptMaxLength + 500);
var messages = ChatAssistantPrompt.BuildMessages(
[
(user, "Frage 2"),
(user, "Frage 1"),
(ChatRules.AiSenderId, "Antwort"),
(user, null), // Bild ohne Text
(user, longText),
]);
Assert.Equal(["user", "assistant", "user"], messages.Select(m => m.Role).ToArray());
Assert.Equal("Frage 1\nFrage 2", messages[2].Text);
Assert.Equal("Antwort", messages[1].Text);
Assert.Equal(ChatRules.AiPromptMaxLength, messages[0].Text.Length);
}
[Fact]
public void Context_never_starts_with_an_assistant_message()
{
var messages = ChatAssistantPrompt.BuildMessages([(Guid.NewGuid(), "Frage"), (ChatRules.AiSenderId, "Willkommen")]);
Assert.Single(messages);
Assert.Equal("user", messages[0].Role);
Assert.Empty(ChatAssistantPrompt.BuildMessages([(ChatRules.AiSenderId, "nur KI")]));
}
[Fact]
public void Reply_is_trimmed_and_cut_to_the_message_length_and_empty_replies_are_rejected()
{
Assert.Null(ChatAssistantPrompt.NormalizeReply(" "));
Assert.Equal("Hallo", ChatAssistantPrompt.NormalizeReply(" Hallo \r\n"));
Assert.Equal(ChatMessage.TextMaxLength, ChatAssistantPrompt.NormalizeReply(new string('a', 5000))!.Length);
}
[Theory]
[InlineData(AiErrorKind.NotConfigured)]
[InlineData(AiErrorKind.LimitReached)]
[InlineData(AiErrorKind.Timeout)]
[InlineData(AiErrorKind.Unauthorized)]
[InlineData(AiErrorKind.Unreachable)]
[InlineData(AiErrorKind.BadResponse)]
public void Error_messages_never_contain_provider_text(AiErrorKind kind)
{
var message = ChatAssistantPrompt.ErrorMessage(new AiException(kind, "SECRET provider text https://internal sk-123"));
Assert.DoesNotContain("SECRET", message);
Assert.DoesNotContain("internal", message);
Assert.DoesNotContain("sk-", message);
Assert.NotEmpty(message);
}
// ---------- GIF-Prüfung ----------
[Fact]
public void Gif_validation_checks_signature_and_size_and_uploads_still_reject_gifs()
{
var gif = Encoding.ASCII.GetBytes("GIF89a").Concat(new byte[100]).ToArray();
Assert.Equal("image/gif", ChatRules.ValidateGif(gif).ContentType);
Assert.Equal("image/gif", ChatRules.ValidateGif(Encoding.ASCII.GetBytes("GIF87a").Concat(new byte[10]).ToArray()).ContentType);
Assert.Null(ChatRules.ValidateGif("<html>GIF89a</html>"u8).ContentType);
Assert.Null(ChatRules.ValidateGif([]).ContentType);
Assert.Null(ChatRules.ValidateGif(Encoding.ASCII.GetBytes("GIF89a").Concat(new byte[ChatRules.MaxGifBytes]).ToArray()).ContentType);
// Hochgeladene Bilder: weiterhin kein GIF.
Assert.Null(ChatRules.ValidateImage(gif).ContentType);
}
[Theory]
[InlineData("abc123", true)]
[InlineData("3o7TKSjRrfIPjIOso", true)]
[InlineData("ab", false)]
[InlineData("../etc/passwd", false)]
[InlineData("a/b", false)]
[InlineData("abc?x=1", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void Gif_ids_are_short_alphanumeric_strings(string? id, bool valid) => Assert.Equal(valid, ChatRules.IsValidGifId(id));
[Fact]
public void Gif_request_validator_rejects_bad_ids_and_empty_message_ids()
{
var validator = new SendGifRequestValidator();
Assert.True(validator.Validate(new SendGifRequest(Guid.NewGuid(), "abc123")).IsValid);
Assert.False(validator.Validate(new SendGifRequest(Guid.NewGuid(), "http://evil.example/x.gif")).IsValid);
Assert.False(validator.Validate(new SendGifRequest(Guid.Empty, "abc123")).IsValid);
}
// ---------- Giphy-Adapter ----------
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public List<Uri> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request.RequestUri!);
return Task.FromResult(respond(request));
}
}
private static GiphyGifProvider Provider(StubHandler handler, string? key = "TESTKEY") =>
new(new HttpClient(handler), Options.Create(new GiphyOptions { ApiKey = key }));
private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
[Fact]
public async Task Search_asks_only_with_the_term_rating_g_and_the_key_on_the_fixed_host()
{
var handler = new StubHandler(_ => Json("""{"data":[{"id":"abc123","title":"Katze"},{"id":"../evil","title":"x"},{"id":"zzz999"}]}"""));
var hits = await Provider(handler).SearchAsync("katze mit hut", 12, TestContext.Current.CancellationToken);
var uri = Assert.Single(handler.Requests);
Assert.Equal("api.giphy.com", uri.Host);
Assert.Equal("https", uri.Scheme);
Assert.Contains("rating=g", uri.Query);
Assert.Contains("q=katze%20mit%20hut", uri.Query);
Assert.Equal(["abc123", "zzz999"], hits.Select(h => h.Id).ToArray());
Assert.Equal("GIF", hits[1].Title);
}
[Fact]
public async Task Search_without_a_key_does_nothing()
{
var handler = new StubHandler(_ => Json("{}"));
var provider = Provider(handler, key: null);
Assert.False(provider.IsConfigured);
Assert.Empty(await provider.SearchAsync("x", 5, TestContext.Current.CancellationToken));
Assert.Empty(handler.Requests);
}
[Fact]
public async Task Search_errors_do_not_leak_the_key_or_provider_text()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.Forbidden) { Content = new StringContent("secret body TESTKEY") });
var ex = await Assert.ThrowsAsync<GifProviderException>(() => Provider(handler).SearchAsync("katze", 5, TestContext.Current.CancellationToken));
Assert.DoesNotContain("TESTKEY", ex.Message);
Assert.DoesNotContain("secret", ex.Message);
}
[Fact]
public async Task Fetch_builds_the_address_from_the_id_on_the_media_host_and_never_follows_redirects()
{
var ok = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent("GIF89a-data"u8.ToArray()) });
var data = await Provider(ok).FetchAsync("abc123", GifVariant.Preview, TestContext.Current.CancellationToken);
Assert.NotNull(data);
Assert.Equal("media.giphy.com", ok.Requests.Single().Host);
Assert.StartsWith("/media/abc123/", ok.Requests.Single().AbsolutePath);
var redirect = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.Found) { Headers = { Location = new Uri("https://evil.example/x.gif") } });
await Assert.ThrowsAsync<GifProviderException>(() => Provider(redirect).FetchAsync("abc123", GifVariant.Message, TestContext.Current.CancellationToken));
Assert.Single(redirect.Requests); // keine zweite Anfrage an den fremden Host
}
[Fact]
public async Task Fetch_ignores_invalid_ids_without_any_request_and_maps_404_to_null()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
var provider = Provider(handler);
Assert.Null(await provider.FetchAsync("../x", GifVariant.Preview, TestContext.Current.CancellationToken));
Assert.Empty(handler.Requests);
Assert.Null(await provider.FetchAsync("abc123", GifVariant.Preview, TestContext.Current.CancellationToken));
}
[Fact]
public async Task Fetch_stops_reading_after_the_size_limit_so_the_caller_can_reject_it()
{
var big = new byte[ChatRules.MaxGifBytes + 50_000];
"GIF89a"u8.CopyTo(big);
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(big) });
var data = await Provider(handler).FetchAsync("abc123", GifVariant.Message, TestContext.Current.CancellationToken);
Assert.NotNull(data);
Assert.True(data.Length <= ChatRules.MaxGifBytes + 1);
Assert.Null(ChatRules.ValidateGif(data).ContentType); // zu groß
}
[Theory]
[InlineData("https://media.giphy.com/media/a/b.gif", true)]
[InlineData("https://api.giphy.com/v1/gifs/search", true)]
[InlineData("http://media.giphy.com/media/a/b.gif", false)]
[InlineData("https://evil.example/media.giphy.com", false)]
[InlineData("https://media.giphy.com.evil.example/x.gif", false)]
[InlineData("https://giphy.com/x", false)]
public void Only_fixed_giphy_hosts_over_https_are_allowed(string url, bool allowed) =>
Assert.Equal(allowed, GiphyGifProvider.IsAllowedHost(new Uri(url)));
}