test: unit tests for chat extensions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
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)));
|
||||
}
|
||||
Reference in new issue
Block a user