test: cover AI rules, adapters, administration UI and integration flows

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 11:03:28 +02:00
1 parent 65993c268b
commit 7fad82ae7a
6 files changed
+1061

No files matched your search

@@ -0,0 +1,378 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Ai;
namespace FamilyPlanner.IntegrationTests;
/// <summary>KI-Anbindung end-to-end: Admin-Verwaltung, Schlüsselschutz, Zweck-Zuordnung, Verbindungstest gegen einen Fake-Anbieter, Tageslimit, Protokoll.</summary>
[Collection(SettingsCollection.Name)]
public sealed class AiTests(AppFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static SaveAiProviderRequest Ollama(string name, string url, AiPurpose purposes = AiPurpose.Default, int limit = 0, int timeout = 30, bool active = true) =>
new(name, AiProviderType.Ollama, url, "llama3.1", null, false, timeout, active, false, purposes, limit);
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<AiProviderDto> CreateAsync(HttpClient admin, SaveAiProviderRequest request)
{
var response = await admin.PostAsJsonAsync("/api/admin/ai/providers", request, Ct);
Assert.True(response.StatusCode == HttpStatusCode.Created, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<AiProviderDto>(Ct))!;
}
private static async Task<AiTestResultDto> TestAsync(HttpClient admin, Guid id)
{
var response = await admin.PostAsync($"/api/admin/ai/providers/{id}/test", null, Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<AiTestResultDto>(Ct))!;
}
[Fact]
public async Task Ai_administration_is_admin_only_and_status_needs_the_ai_permission()
{
var (_, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
using var _f = family;
using var _c = child;
using var _g = guest;
using var anonymous = fixture.CreateBrowserClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/admin/ai/providers", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/ai/status", Ct)).StatusCode);
foreach (var client in new[] { family, child, guest })
{
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/admin/ai/providers", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsJsonAsync("/api/admin/ai/providers", Ollama("x", "http://localhost:11434"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync($"/api/admin/ai/providers/{Guid.NewGuid()}/test", null, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/admin/ai/usage", Ct)).StatusCode);
}
Assert.Equal(HttpStatusCode.OK, (await family.GetAsync("/api/ai/status", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await child.GetAsync("/api/ai/status", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await guest.GetAsync("/api/ai/status", Ct)).StatusCode);
}
[Fact]
public async Task Provider_input_is_validated_including_addresses_keys_and_purposes()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
await ClearProvidersAsync(admin);
async Task<HttpStatusCode> PostAsync(SaveAiProviderRequest request) =>
(await admin.PostAsJsonAsync("/api/admin/ai/providers", request, Ct)).StatusCode;
var good = Ollama("Lokal", "http://localhost:11434");
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Name = " " }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Model = "" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { TimeoutSeconds = 1 }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { TimeoutSeconds = 9999 }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { DailyLimitPerUser = -1 }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Purposes = (AiPurpose)64 }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Type = (AiProviderType)9 }));
// Adressen: Schema, Zugangsdaten, Cloud-Metadaten, Klartext außerhalb des Heimnetzes, Claude nur https
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { BaseUrl = "ftp://localhost" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { BaseUrl = "http://user:pw@localhost:11434" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { BaseUrl = "http://169.254.169.254" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { BaseUrl = "http://api.example.com" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Type = AiProviderType.Claude, BaseUrl = "http://localhost:1234", ApiKey = "sk-test" }));
// Schlüssel: Claude/Cloud brauchen einen, Leerzeichen sind unzulässig, Entfernen + Setzen schließt sich aus
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Type = AiProviderType.Claude, BaseUrl = null }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Type = AiProviderType.OpenAiCompatible, BaseUrl = null }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Type = AiProviderType.Claude, ApiKey = "sk test" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { Type = AiProviderType.Claude, ApiKey = "abc", ClearApiKey = true }));
// Zwecke: inaktiv ohne Zweck, Foto-Prüfung bei Cloud nur mit Opt-in
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(good with { IsActive = false, Purposes = AiPurpose.Default }));
var cloud = new SaveAiProviderRequest("Cloud", AiProviderType.OpenAiCompatible, "https://api.perplexity.ai", "sonar", "pplx-secret", false, 30, true, false,
AiPurpose.LearningImageGrading, 10);
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(cloud));
Assert.Equal(HttpStatusCode.Created, await PostAsync(cloud with { AllowImages = true }));
Assert.Equal(HttpStatusCode.Created, await PostAsync(good with { Name = "Lokal ok", BaseUrl = "http://localhost:11434", Purposes = AiPurpose.LearningImageGrading }));
}
[Fact]
public async Task Api_keys_never_leave_the_server_and_are_kept_replaced_or_cleared_on_request()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
await ClearProvidersAsync(admin);
const string secret = "sk-ant-GEHEIM-1234567890";
var create = new SaveAiProviderRequest("Claude", AiProviderType.Claude, null, "claude-sonnet-5-5", secret, false, 60, true, false, AiPurpose.None, 5);
var response = await admin.PostAsJsonAsync("/api/admin/ai/providers", create, Ct);
var raw = await response.Content.ReadAsStringAsync(Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.DoesNotContain(secret, raw);
var created = JsonSerializer.Deserialize<AiProviderDto>(raw, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
Assert.True(created.HasApiKey);
Assert.Equal("https://api.anthropic.com", created.BaseUrl);
Assert.False(created.IsHomeNetwork);
Assert.DoesNotContain(secret, await admin.GetStringAsync("/api/admin/ai/providers", Ct));
// Ohne ApiKey bleibt der Schlüssel erhalten, mit ClearApiKey wird er entfernt (und Claude verlangt dann einen neuen).
var keep = await admin.PutAsJsonAsync($"/api/admin/ai/providers/{created.Id}", create with { ApiKey = null, Model = "claude-haiku-5-5", Version = created.Version }, Ct);
Assert.Equal(HttpStatusCode.OK, keep.StatusCode);
var kept = (await keep.Content.ReadFromJsonAsync<AiProviderDto>(Ct))!;
Assert.True(kept.HasApiKey);
Assert.Equal("claude-haiku-5-5", kept.Model);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PutAsJsonAsync($"/api/admin/ai/providers/{created.Id}", create with { ApiKey = null, ClearApiKey = true }, Ct)).StatusCode);
// Veraltete Version → 409
Assert.Equal(HttpStatusCode.Conflict, (await admin.PutAsJsonAsync($"/api/admin/ai/providers/{created.Id}", create with { ApiKey = null, Version = created.Version }, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await admin.PutAsJsonAsync($"/api/admin/ai/providers/{Guid.NewGuid()}", create, Ct)).StatusCode);
// Ein Ollama im Heimnetz braucht keinen Schlüssel; Löschen ist idempotent und entfernt den Eintrag aus der Liste.
var local = await CreateAsync(admin, Ollama("Heim", "http://localhost:11434"));
Assert.False(local.HasApiKey);
Assert.True(local.IsHomeNetwork);
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/ai/providers/{local.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/ai/providers/{local.Id}", Ct)).StatusCode);
Assert.DoesNotContain((await admin.GetFromJsonAsync<List<AiProviderDto>>("/api/admin/ai/providers", Ct))!, p => p.Id == local.Id);
// Audit-Log ohne Schlüssel
var audit = await admin.GetStringAsync("/api/admin/audit?pageSize=50", Ct);
Assert.Contains("ai.provider_created", audit);
Assert.DoesNotContain(secret, audit);
}
[Fact]
public async Task Each_purpose_belongs_to_exactly_one_provider_and_status_reflects_the_setup()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (_, family) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _f = family;
await ClearProvidersAsync(admin);
var status = (await family.GetFromJsonAsync<AiStatusDto>("/api/ai/status", Ct))!;
Assert.Equal((false, false, false), (status.Available, status.CanGenerateLearning, status.CanGradeImages));
var first = await CreateAsync(admin, Ollama("Erster", "http://localhost:11434", AiPurpose.Default | AiPurpose.LearningGeneration));
var second = await CreateAsync(admin, Ollama("Zweiter", "http://localhost:11435", AiPurpose.Default));
var providers = (await admin.GetFromJsonAsync<List<AiProviderDto>>("/api/admin/ai/providers", Ct))!;
Assert.Equal(AiPurpose.LearningGeneration, providers.Single(p => p.Id == first.Id).Purposes); // Default ging an den neuen
Assert.Equal(AiPurpose.Default, providers.Single(p => p.Id == second.Id).Purposes);
status = (await family.GetFromJsonAsync<AiStatusDto>("/api/ai/status", Ct))!;
Assert.Equal((true, true, false), (status.Available, status.CanGenerateLearning, status.CanGradeImages));
// Foto-Prüfung im Heimnetz braucht kein Opt-in
await admin.PutAsJsonAsync($"/api/admin/ai/providers/{second.Id}", Ollama("Zweiter", "http://localhost:11435", AiPurpose.Default | AiPurpose.LearningImageGrading), Ct);
Assert.True((await family.GetFromJsonAsync<AiStatusDto>("/api/ai/status", Ct))!.CanGradeImages);
// Deaktivierte Anbieter zählen nicht
await admin.PutAsJsonAsync($"/api/admin/ai/providers/{first.Id}", Ollama("Erster", "http://localhost:11434", AiPurpose.None, active: false), Ct);
await admin.PutAsJsonAsync($"/api/admin/ai/providers/{second.Id}", Ollama("Zweiter", "http://localhost:11435", AiPurpose.None, active: false), Ct);
Assert.False((await family.GetFromJsonAsync<AiStatusDto>("/api/ai/status", Ct))!.Available);
}
[Fact]
public async Task Connection_test_talks_to_the_provider_and_reports_success_and_failures_in_plain_german()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
await ClearProvidersAsync(admin);
await using var server = FakeAiServer.Start();
var provider = await CreateAsync(admin, Ollama("Fake-Ollama", server.BaseUrl, timeout: 5));
server.Respond = _ => (200, """{"message":{"role":"assistant","content":"OK"},"prompt_eval_count":12,"eval_count":3}""", TimeSpan.Zero);
var ok = await TestAsync(admin, provider.Id);
Assert.True(ok.Success, ok.Message);
Assert.Equal("OK", ok.Reply);
var request = server.Requests.Single();
Assert.Equal("/api/chat", request.Path);
using (var body = JsonDocument.Parse(request.Body))
{
Assert.Equal("llama3.1", body.RootElement.GetProperty("model").GetString());
Assert.False(body.RootElement.GetProperty("stream").GetBoolean());
Assert.Contains("OK", body.RootElement.GetProperty("messages")[0].GetProperty("content").GetString());
}
foreach (var (status, expected) in new[] { (401, "API-Schlüssel"), (404, "nicht gefunden"), (429, "überlastet"), (500, "Fehler gemeldet") })
{
server.Respond = _ => (status, """{"error":"geheimer Anbietertext"}""", TimeSpan.Zero);
var failed = await TestAsync(admin, provider.Id);
Assert.False(failed.Success);
Assert.Contains(expected, failed.Message);
Assert.DoesNotContain("geheimer Anbietertext", failed.Message);
}
server.Respond = _ => (200, "das ist kein JSON", TimeSpan.Zero);
Assert.Contains("verständliche Antwort", (await TestAsync(admin, provider.Id)).Message);
server.Respond = _ => (200, """{"message":{"content":"zu spät"}}""", TimeSpan.FromSeconds(8));
var slow = await TestAsync(admin, provider.Id);
Assert.False(slow.Success);
Assert.Contains("nicht rechtzeitig", slow.Message);
var dead = await CreateAsync(admin, Ollama("Tot", "http://localhost:1"));
Assert.Contains("nicht erreichbar", (await TestAsync(admin, dead.Id)).Message);
Assert.Equal(HttpStatusCode.NotFound, (await admin.PostAsync($"/api/admin/ai/providers/{Guid.NewGuid()}/test", null, Ct)).StatusCode);
}
[Fact]
public async Task OpenAI_compatible_adapter_sends_bearer_key_and_the_chat_completions_format()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
await ClearProvidersAsync(admin);
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, """{"choices":[{"message":{"role":"assistant","content":"OK"}}],"usage":{"prompt_tokens":7,"completion_tokens":1}}""", TimeSpan.Zero);
var provider = await CreateAsync(admin, new SaveAiProviderRequest("LM Studio", AiProviderType.OpenAiCompatible, server.BaseUrl, "sonar", "pplx-key-123", false, 30, true, false, AiPurpose.None, 0));
var result = await TestAsync(admin, provider.Id);
Assert.True(result.Success, result.Message);
var request = server.Requests.Single();
Assert.Equal("/chat/completions", request.Path);
Assert.Equal("Bearer pplx-key-123", request.Authorization);
using var body = JsonDocument.Parse(request.Body);
Assert.Equal("sonar", body.RootElement.GetProperty("model").GetString());
Assert.Equal("user", body.RootElement.GetProperty("messages")[0].GetProperty("role").GetString());
}
[Fact]
public async Task Daily_limit_is_enforced_per_provider_and_all_calls_show_up_in_the_usage_log()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
await ClearProvidersAsync(admin);
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, """{"message":{"content":"OK"},"prompt_eval_count":5,"eval_count":2}""", TimeSpan.Zero);
var limited = await CreateAsync(admin, Ollama("Begrenzt", server.BaseUrl, limit: 2));
var unlimited = await CreateAsync(admin, Ollama("Unbegrenzt", server.BaseUrl, limit: 0));
var before = (await admin.GetFromJsonAsync<AiUsageOverviewDto>("/api/admin/ai/usage", Ct))!.CallsToday;
Assert.True((await TestAsync(admin, limited.Id)).Success);
Assert.True((await TestAsync(admin, limited.Id)).Success);
var blocked = await TestAsync(admin, limited.Id);
Assert.False(blocked.Success);
Assert.Contains("Tageslimit von 2", blocked.Message);
Assert.Equal(2, server.Requests.Count); // der gesperrte Aufruf erreicht den Anbieter nicht
Assert.True((await TestAsync(admin, unlimited.Id)).Success);
var usage = (await admin.GetFromJsonAsync<AiUsageOverviewDto>("/api/admin/ai/usage", Ct))!;
Assert.Equal(before + 4, usage.CallsToday);
var mine = usage.Recent.Where(u => u.ProviderName is "Begrenzt" or "Unbegrenzt").ToList();
Assert.Equal(4, mine.Count);
Assert.Equal(3, mine.Count(u => u.Success));
var failure = Assert.Single(mine, u => !u.Success);
Assert.Equal("LimitReached", failure.Error);
var success = mine.First(u => u.Success);
Assert.Equal((5, 2, "llama3.1", false), (success.InputTokens, success.OutputTokens, success.Model, success.HadImages));
Assert.True(usage.FailedToday >= 1);
// Erhöhen des Limits wirkt sofort
await admin.PutAsJsonAsync($"/api/admin/ai/providers/{limited.Id}", Ollama("Begrenzt", server.BaseUrl, limit: 3), Ct);
Assert.True((await TestAsync(admin, limited.Id)).Success);
}
/// <summary>Winziger HTTP-Server, der einen KI-Anbieter vortäuscht (zeichnet Anfragen auf, Antwort je Test einstellbar).</summary>
private sealed class FakeAiServer : IAsyncDisposable
{
private readonly HttpListener _listener = new();
private readonly CancellationTokenSource _stop = new();
private readonly List<RecordedRequest> _requests = [];
private FakeAiServer(int port)
{
BaseUrl = $"http://localhost:{port}";
_listener.Prefixes.Add($"{BaseUrl}/");
}
public string BaseUrl { get; }
public Func<RecordedRequest, (int Status, string Body, TimeSpan Delay)> Respond { get; set; } = _ => (200, "{}", TimeSpan.Zero);
public IReadOnlyList<RecordedRequest> Requests
{
get
{
lock (_requests)
{
return [.. _requests];
}
}
}
public static FakeAiServer Start()
{
var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
probe.Start();
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
var server = new FakeAiServer(port);
server._listener.Start();
_ = server.LoopAsync();
return server;
}
private async Task LoopAsync()
{
while (!_stop.IsCancellationRequested)
{
HttpListenerContext context;
try
{
context = await _listener.GetContextAsync();
}
catch (Exception) when (_stop.IsCancellationRequested || !_listener.IsListening)
{
return;
}
_ = Task.Run(() => HandleAsync(context));
}
}
private async Task HandleAsync(HttpListenerContext context)
{
using var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8);
var recorded = new RecordedRequest(context.Request.Url!.AbsolutePath, await reader.ReadToEndAsync(), context.Request.Headers["Authorization"], context.Request.Headers["x-api-key"]);
lock (_requests)
{
_requests.Add(recorded);
}
var (status, body, delay) = Respond(recorded);
try
{
if (delay > TimeSpan.Zero)
{
await Task.Delay(delay, _stop.Token);
}
var bytes = Encoding.UTF8.GetBytes(body);
context.Response.StatusCode = status;
context.Response.ContentType = "application/json";
await context.Response.OutputStream.WriteAsync(bytes, _stop.Token);
context.Response.Close();
}
catch (Exception)
{
// Client hat aufgegeben (Timeout-Test) oder der Server wird beendet.
}
}
public ValueTask DisposeAsync()
{
_stop.Cancel();
_listener.Close();
return ValueTask.CompletedTask;
}
}
private sealed record RecordedRequest(string Path, string Body, string? Authorization, string? ApiKey);
}
@@ -0,0 +1,224 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using FamilyPlanner.Api.Features.Ai;
using FamilyPlanner.Domain.Ai;
namespace FamilyPlanner.UnitTests.Ai;
/// <summary>Anbieter-Adapter gegen einen Fake-HTTP-Handler: Anfrageformat, Antwortauswertung, Fehlerübersetzung.</summary>
public sealed class AiAdapterTests
{
private static readonly AiImage Image = new("image/png", [1, 2, 3]);
private static readonly AiJsonSchema Schema = new("aufgaben", "Liste von Aufgaben", JsonNode.Parse("""{"type":"object","properties":{"items":{"type":"array"}}}""")!);
private sealed class FakeHandler(Func<HttpRequestMessage, string?, (HttpStatusCode Status, string Body)> respond) : HttpMessageHandler
{
public List<(HttpRequestMessage Request, string? Body)> Calls { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
Calls.Add((request, body));
var (status, text) = respond(request, body);
return new HttpResponseMessage(status) { Content = new StringContent(text, Encoding.UTF8, "application/json") };
}
}
private sealed class Factory(HttpMessageHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
}
private static (T Provider, FakeHandler Handler) Make<T>(Func<IHttpClientFactory, T> create, HttpStatusCode status, string body)
{
var handler = new FakeHandler((_, _) => (status, body));
return (create(new Factory(handler)), handler);
}
private static JsonDocument Json(string? text) => JsonDocument.Parse(text ?? throw new InvalidOperationException("kein Body"));
[Fact]
public async Task Ollama_sends_chat_request_with_images_and_schema_and_reads_tokens()
{
var (provider, handler) = Make(f => new OllamaProvider(f), HttpStatusCode.OK,
"""{"message":{"role":"assistant","content":"{\"items\":[1,2]}"},"prompt_eval_count":11,"eval_count":4}""");
var request = new AiRequest("Sei Lehrer.", [AiMessage.User("Was siehst du?", Image)], Schema, 500);
var response = await provider.CompleteAsync(new AiProviderSettings(AiProviderType.Ollama, "http://nas:11434/", "qwen2.5vl", null), request, TestContext.Current.CancellationToken);
var (sent, body) = Assert.Single(handler.Calls);
Assert.Equal("http://nas:11434/api/chat", sent.RequestUri!.ToString());
Assert.Null(sent.Headers.Authorization);
using var json = Json(body);
var root = json.RootElement;
Assert.Equal("qwen2.5vl", root.GetProperty("model").GetString());
Assert.False(root.GetProperty("stream").GetBoolean());
Assert.Equal(500, root.GetProperty("options").GetProperty("num_predict").GetInt32());
Assert.Equal("object", root.GetProperty("format").GetProperty("type").GetString());
var messages = root.GetProperty("messages");
Assert.Equal(("system", "Sei Lehrer."), (messages[0].GetProperty("role").GetString(), messages[0].GetProperty("content").GetString()));
Assert.Equal(Convert.ToBase64String([1, 2, 3]), messages[1].GetProperty("images")[0].GetString());
Assert.Equal(2, response.Json!.Value.GetProperty("items").GetArrayLength());
Assert.Equal((11, 4), (response.InputTokens, response.OutputTokens));
}
[Fact]
public async Task Ollama_sends_bearer_only_when_a_key_is_configured_and_plain_text_has_no_json()
{
var (provider, handler) = Make(f => new OllamaProvider(f), HttpStatusCode.OK, """{"message":{"content":"Hallo"}}""");
var response = await provider.CompleteAsync(new AiProviderSettings(AiProviderType.Ollama, "http://localhost:11434", "m", "key-1"), AiRequest.Simple("Hi"), TestContext.Current.CancellationToken);
Assert.Equal("Bearer key-1", handler.Calls[0].Request.Headers.Authorization!.ToString());
Assert.Equal("Hallo", response.Text);
Assert.Null(response.Json);
using var json = Json(handler.Calls[0].Body);
Assert.False(json.RootElement.TryGetProperty("format", out _));
}
[Fact]
public async Task Claude_uses_messages_api_headers_image_blocks_and_forced_tool_for_the_schema()
{
var (provider, handler) = Make(f => new ClaudeProvider(f), HttpStatusCode.OK,
"""{"content":[{"type":"tool_use","name":"aufgaben","input":{"items":[{"q":"1+1"}]}}],"usage":{"input_tokens":30,"output_tokens":9}}""");
var request = new AiRequest("System", [AiMessage.User("Prüfe das Foto", Image)], Schema, 1000);
var response = await provider.CompleteAsync(new AiProviderSettings(AiProviderType.Claude, "https://api.anthropic.com", "claude-sonnet-5-5", "sk-ant-1"), request, TestContext.Current.CancellationToken);
var (sent, body) = Assert.Single(handler.Calls);
Assert.Equal("https://api.anthropic.com/v1/messages", sent.RequestUri!.ToString());
Assert.Equal("sk-ant-1", sent.Headers.GetValues("x-api-key").Single());
Assert.Equal(ClaudeProvider.ApiVersion, sent.Headers.GetValues("anthropic-version").Single());
using var json = Json(body);
var root = json.RootElement;
Assert.Equal(("claude-sonnet-5-5", 1000, "System"), (root.GetProperty("model").GetString(), root.GetProperty("max_tokens").GetInt32(), root.GetProperty("system").GetString()));
var content = root.GetProperty("messages")[0].GetProperty("content");
Assert.Equal("image", content[0].GetProperty("type").GetString());
Assert.Equal(("base64", "image/png"), (content[0].GetProperty("source").GetProperty("type").GetString(), content[0].GetProperty("source").GetProperty("media_type").GetString()));
Assert.Equal("text", content[1].GetProperty("type").GetString());
Assert.Equal("aufgaben", root.GetProperty("tool_choice").GetProperty("name").GetString());
Assert.Equal("tool", root.GetProperty("tool_choice").GetProperty("type").GetString());
Assert.Equal("1+1", response.Json!.Value.GetProperty("items")[0].GetProperty("q").GetString());
Assert.Equal((30, 9), (response.InputTokens, response.OutputTokens));
}
[Fact]
public async Task Claude_plain_text_request_has_no_tools_and_joins_text_blocks()
{
var (provider, handler) = Make(f => new ClaudeProvider(f), HttpStatusCode.OK, """{"content":[{"type":"text","text":"Hallo "},{"type":"text","text":"Welt"}]}""");
var response = await provider.CompleteAsync(new AiProviderSettings(AiProviderType.Claude, "https://api.anthropic.com", "m", "k"), AiRequest.Simple("Hi"), TestContext.Current.CancellationToken);
Assert.Equal("Hallo Welt", response.Text);
using var json = Json(handler.Calls[0].Body);
Assert.False(json.RootElement.TryGetProperty("tools", out _));
Assert.False(json.RootElement.TryGetProperty("system", out _));
Assert.Null(response.InputTokens);
}
[Fact]
public async Task OpenAI_compatible_uses_chat_completions_bearer_data_urls_and_json_schema()
{
var (provider, handler) = Make(f => new OpenAiCompatibleProvider(f), HttpStatusCode.OK,
"""{"choices":[{"message":{"role":"assistant","content":"```json\n{\"items\":[]}\n```"}}],"usage":{"prompt_tokens":8,"completion_tokens":2}}""");
var request = new AiRequest("System", [AiMessage.User("Foto", Image)], Schema, 300);
var response = await provider.CompleteAsync(new AiProviderSettings(AiProviderType.OpenAiCompatible, "https://api.perplexity.ai/", "sonar", "pplx-1"), request, TestContext.Current.CancellationToken);
var (sent, body) = Assert.Single(handler.Calls);
Assert.Equal("https://api.perplexity.ai/chat/completions", sent.RequestUri!.ToString());
Assert.Equal("Bearer pplx-1", sent.Headers.Authorization!.ToString());
using var json = Json(body);
var root = json.RootElement;
Assert.Equal("json_schema", root.GetProperty("response_format").GetProperty("type").GetString());
Assert.Equal("aufgaben", root.GetProperty("response_format").GetProperty("json_schema").GetProperty("name").GetString());
var user = root.GetProperty("messages")[1].GetProperty("content");
Assert.Equal("text", user[0].GetProperty("type").GetString());
Assert.StartsWith("data:image/png;base64,", user[1].GetProperty("image_url").GetProperty("url").GetString());
Assert.Equal(JsonValueKind.Object, response.Json!.Value.ValueKind); // Markdown-Zaun wird entfernt
Assert.Equal((8, 2), (response.InputTokens, response.OutputTokens));
}
[Theory]
[InlineData(401, AiErrorKind.Unauthorized)]
[InlineData(403, AiErrorKind.Unauthorized)]
[InlineData(404, AiErrorKind.Misconfigured)]
[InlineData(408, AiErrorKind.Timeout)]
[InlineData(429, AiErrorKind.RateLimited)]
[InlineData(500, AiErrorKind.Unavailable)]
[InlineData(503, AiErrorKind.Unavailable)]
[InlineData(400, AiErrorKind.BadResponse)]
public async Task Http_errors_become_ai_exceptions_without_leaking_provider_text(int status, AiErrorKind expected)
{
var (provider, _) = Make(f => new OpenAiCompatibleProvider(f), (HttpStatusCode)status, """{"error":"sk-secret-im-anbietertext"}""");
var ex = await Assert.ThrowsAsync<AiException>(() => provider.CompleteAsync(
new AiProviderSettings(AiProviderType.OpenAiCompatible, "https://x.example.com", "m", "k"), AiRequest.Simple("Hi"), TestContext.Current.CancellationToken));
Assert.Equal(expected, ex.Kind);
Assert.DoesNotContain("sk-secret", ex.Message);
Assert.False(string.IsNullOrWhiteSpace(ex.Message));
}
[Fact]
public async Task Garbage_body_is_a_bad_response_and_network_failure_is_unreachable()
{
var (provider, _) = Make(f => new OllamaProvider(f), HttpStatusCode.OK, "<html>Proxy-Fehlerseite</html>");
var bad = await Assert.ThrowsAsync<AiException>(() => provider.CompleteAsync(
new AiProviderSettings(AiProviderType.Ollama, "http://localhost", "m", null), AiRequest.Simple("Hi"), TestContext.Current.CancellationToken));
Assert.Equal(AiErrorKind.BadResponse, bad.Kind);
var failing = new OllamaProvider(new Factory(new FakeHandler((_, _) => throw new HttpRequestException("Verbindung abgelehnt"))));
var down = await Assert.ThrowsAsync<AiException>(() => failing.CompleteAsync(
new AiProviderSettings(AiProviderType.Ollama, "http://localhost", "m", null), AiRequest.Simple("Hi"), TestContext.Current.CancellationToken));
Assert.Equal(AiErrorKind.Unreachable, down.Kind);
}
[Fact]
public async Task Oversized_responses_are_rejected()
{
var huge = "{\"message\":{\"content\":\"" + new string('x', 5 * 1024 * 1024) + "\"}}";
var (provider, _) = Make(f => new OllamaProvider(f), HttpStatusCode.OK, huge);
var ex = await Assert.ThrowsAsync<AiException>(() => provider.CompleteAsync(
new AiProviderSettings(AiProviderType.Ollama, "http://localhost", "m", null), AiRequest.Simple("Hi"), TestContext.Current.CancellationToken));
Assert.Equal(AiErrorKind.BadResponse, ex.Kind);
}
[Theory]
[InlineData("""{"a":1}""", true)]
[InlineData(" {\"a\":1} ", true)]
[InlineData("```json\n{\"a\":1}\n```", true)]
[InlineData("```\n[1,2,3]\n```", true)]
[InlineData("Hier ist das Ergebnis: {\"a\":1} Viel Erfolg!", true)]
[InlineData("Hier: [1,2]", true)]
[InlineData("kein json", false)]
[InlineData("{kaputt", false)]
[InlineData("", false)]
[InlineData(null, false)]
[InlineData("\"nur ein String\"", false)]
public void Json_is_extracted_from_model_text(string? text, bool found) => Assert.Equal(found, AiHttp.ExtractJson(text) is not null);
[Fact]
public void Exceptions_map_to_http_statuses_and_labels()
{
Assert.Equal(429, new AiException(AiErrorKind.LimitReached, "x").HttpStatus);
Assert.Equal(429, new AiException(AiErrorKind.RateLimited, "x").HttpStatus);
Assert.Equal(409, new AiException(AiErrorKind.NotConfigured, "x").HttpStatus);
Assert.Equal(409, new AiException(AiErrorKind.ImagesNotAllowed, "x").HttpStatus);
Assert.Equal(504, new AiException(AiErrorKind.Timeout, "x").HttpStatus);
Assert.Equal(502, new AiException(AiErrorKind.Unreachable, "x").HttpStatus);
Assert.Equal("Timeout", new AiException(AiErrorKind.Timeout, "x").Label);
}
[Fact]
public void Request_knows_about_images() =>
Assert.True(new AiRequest(null, [AiMessage.User("a"), AiMessage.User("b", Image)]).HasImages);
[Fact]
public void Request_without_images_has_none() => Assert.False(AiRequest.Simple("a").HasImages);
}
@@ -0,0 +1,179 @@
using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Shared.Ai;
namespace FamilyPlanner.UnitTests.Ai;
/// <summary>Regeln der KI-Anbindung: Adressen, Heimnetz, Schlüsselpflicht, Zwecke, Tageslimit, Validierung.</summary>
public sealed class AiRulesTests
{
[Theory]
[InlineData(AiProviderType.Ollama, null, "http://localhost:11434")]
[InlineData(AiProviderType.Ollama, " ", "http://localhost:11434")]
[InlineData(AiProviderType.Claude, null, "https://api.anthropic.com")]
[InlineData(AiProviderType.OpenAiCompatible, "", "https://api.perplexity.ai")]
[InlineData(AiProviderType.Ollama, "http://192.168.1.20:11434/", "http://192.168.1.20:11434")]
[InlineData(AiProviderType.Ollama, "http://ollama:11434", "http://ollama:11434")]
[InlineData(AiProviderType.Ollama, "http://nas.local:11434", "http://nas.local:11434")]
[InlineData(AiProviderType.OpenAiCompatible, "https://models.github.ai/inference/", "https://models.github.ai/inference")]
[InlineData(AiProviderType.OpenAiCompatible, "http://127.0.0.1:1234/v1", "http://127.0.0.1:1234/v1")]
[InlineData(AiProviderType.Ollama, "https://ollama.example.com", "https://ollama.example.com")]
public void Valid_addresses_are_normalized(AiProviderType type, string? input, string expected)
{
var (url, error) = AiRules.NormalizeBaseUrl(type, input);
Assert.Null(error);
Assert.Equal(expected, url);
}
[Theory]
[InlineData(AiProviderType.Ollama, "ftp://localhost")]
[InlineData(AiProviderType.Ollama, "file:///etc/passwd")]
[InlineData(AiProviderType.Ollama, "localhost:11434")]
[InlineData(AiProviderType.Ollama, "javascript:alert(1)")]
[InlineData(AiProviderType.Ollama, "http://user:secret@localhost:11434")]
[InlineData(AiProviderType.Ollama, "http://localhost:11434?key=1")]
[InlineData(AiProviderType.Ollama, "http://localhost:11434/#x")]
[InlineData(AiProviderType.Ollama, "http://169.254.169.254/latest/meta-data")]
[InlineData(AiProviderType.Ollama, "http://[fe80::1]/")]
[InlineData(AiProviderType.Ollama, "http://[::ffff:169.254.169.254]/")]
[InlineData(AiProviderType.Ollama, "http://0.0.0.0:11434")]
[InlineData(AiProviderType.Ollama, "http://api.example.com")]
[InlineData(AiProviderType.OpenAiCompatible, "http://api.perplexity.ai")]
[InlineData(AiProviderType.Claude, "http://localhost:8080")]
[InlineData(AiProviderType.Claude, "http://192.168.1.5")]
public void Unsafe_or_malformed_addresses_are_rejected(AiProviderType type, string input)
{
var (url, error) = AiRules.NormalizeBaseUrl(type, input);
Assert.Null(url);
Assert.False(string.IsNullOrEmpty(error));
}
[Fact]
public void Overlong_addresses_are_rejected() =>
Assert.NotNull(AiRules.NormalizeBaseUrl(AiProviderType.Ollama, "http://localhost/" + new string('a', 400)).Error);
[Theory]
[InlineData("http://localhost:11434", true)]
[InlineData("http://127.0.0.1", true)]
[InlineData("http://[::1]:11434", true)]
[InlineData("http://10.0.0.5", true)]
[InlineData("http://172.16.0.1", true)]
[InlineData("http://172.31.255.254", true)]
[InlineData("http://192.168.178.20", true)]
[InlineData("http://100.100.1.1", true)] // CGNAT (z. B. Tailscale)
[InlineData("http://[fd12:3456::1]", true)]
[InlineData("http://ollama", true)]
[InlineData("http://host.docker.internal:11434", true)]
[InlineData("http://nas.local", true)]
[InlineData("http://server.lan", true)]
[InlineData("https://api.anthropic.com", false)]
[InlineData("https://api.perplexity.ai", false)]
[InlineData("https://8.8.8.8", false)]
[InlineData("http://172.32.0.1", false)]
[InlineData("http://192.169.0.1", false)]
[InlineData("http://100.128.0.1", false)]
[InlineData("https://local.example.com", false)]
[InlineData("https://notlocal.com", false)]
public void Home_network_detection(string url, bool expected) => Assert.Equal(expected, AiRules.IsHomeNetwork(url));
[Fact]
public void Home_network_check_of_garbage_is_false() => Assert.False(AiRules.IsHomeNetwork("kein url"));
[Theory]
[InlineData(false, "http://localhost:11434", true)] // Heimnetz braucht kein Opt-in
[InlineData(false, "https://api.anthropic.com", false)]
[InlineData(true, "https://api.anthropic.com", true)] // ausdrücklich erlaubt
[InlineData(true, "http://localhost:11434", true)]
public void Images_need_opt_in_outside_the_home_network(bool allowImages, string url, bool expected) =>
Assert.Equal(expected, AiRules.MayReceiveImages(allowImages, url));
[Theory]
[InlineData(AiProviderType.Claude, "https://api.anthropic.com", true)]
[InlineData(AiProviderType.Claude, "https://proxy.local", true)]
[InlineData(AiProviderType.Ollama, "http://localhost:11434", false)]
[InlineData(AiProviderType.Ollama, "https://ollama.example.com", false)]
[InlineData(AiProviderType.OpenAiCompatible, "https://api.perplexity.ai", true)]
[InlineData(AiProviderType.OpenAiCompatible, "http://localhost:1234/v1", false)]
public void Api_key_requirement(AiProviderType type, string url, bool expected) => Assert.Equal(expected, AiRules.RequiresApiKey(type, url));
[Theory]
[InlineData(0, 0, true)]
[InlineData(1000, 0, true)] // 0 = unbegrenzt
[InlineData(5, -1, true)]
[InlineData(0, 3, true)]
[InlineData(2, 3, true)]
[InlineData(3, 3, false)]
[InlineData(4, 3, false)]
public void Daily_limit(int used, int limit, bool allowed) => Assert.Equal(allowed, AiRules.IsWithinDailyLimit(used, limit));
[Fact]
public void Purposes_are_distinct_bits_with_names()
{
Assert.Equal(3, 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)));
Assert.False(AiRules.IsSinglePurpose(AiPurpose.None));
Assert.False(AiRules.IsSinglePurpose(AiPurpose.Default | AiPurpose.LearningGeneration));
Assert.True(AiRules.IsValidPurposes(AiPurpose.None));
Assert.True(AiRules.IsValidPurposes(AiRules.AllPurposes));
Assert.False(AiRules.IsValidPurposes((AiPurpose)64));
}
[Fact]
public void Every_provider_type_has_defaults_that_are_themselves_valid()
{
Assert.Equal(Enum.GetValues<AiProviderType>().Length, AiRules.Types.Count);
foreach (var info in AiRules.Types)
{
Assert.Null(AiRules.NormalizeBaseUrl(info.Type, info.DefaultBaseUrl).Error);
Assert.False(string.IsNullOrWhiteSpace(info.DefaultModel));
Assert.Equal(info, AiRules.Info(info.Type));
}
Assert.Contains("Copilot", AiRules.Info(AiProviderType.OpenAiCompatible).Hint); // Hinweis zur fehlenden Copilot-API
}
private static SaveAiProviderRequest Valid() =>
new("Lokal", AiProviderType.Ollama, "http://localhost:11434", "llama3.1", null, false, 60, true, false, AiPurpose.Default, 50);
[Fact]
public void Valid_request_passes_validation() => Assert.True(new SaveAiProviderRequestValidator().Validate(Valid()).IsValid);
[Fact]
public void Validator_reports_each_problem_on_its_field()
{
var validator = new SaveAiProviderRequestValidator();
void Expect(SaveAiProviderRequest request, string field) =>
Assert.Contains(validator.Validate(request).Errors, e => e.PropertyName == field);
Expect(Valid() with { Name = "" }, "Name");
Expect(Valid() with { Name = new string('x', 81) }, "Name");
Expect(Valid() with { Model = " " }, "Model");
Expect(Valid() with { TimeoutSeconds = 4 }, "TimeoutSeconds");
Expect(Valid() with { TimeoutSeconds = 301 }, "TimeoutSeconds");
Expect(Valid() with { DailyLimitPerUser = -1 }, "DailyLimitPerUser");
Expect(Valid() with { DailyLimitPerUser = AiRules.MaxDailyLimit + 1 }, "DailyLimitPerUser");
Expect(Valid() with { BaseUrl = "http://169.254.169.254" }, "BaseUrl");
Expect(Valid() with { Type = (AiProviderType)7 }, "Type");
Expect(Valid() with { Purposes = (AiPurpose)32 }, "Purposes");
Expect(Valid() with { ApiKey = "mit leerzeichen" }, "ApiKey");
Expect(Valid() with { ApiKey = "" }, "ApiKey");
Expect(Valid() with { ApiKey = new string('k', 501) }, "ApiKey");
Expect(Valid() with { ApiKey = "abc", ClearApiKey = true }, "ApiKey");
Expect(Valid() with { IsActive = false }, "Purposes");
}
[Fact]
public void Photo_grading_in_the_cloud_requires_the_image_opt_in()
{
var cloud = new SaveAiProviderRequest("Cloud", AiProviderType.Claude, null, "claude-sonnet-5-5", "sk-1", false, 60, true, false, AiPurpose.LearningImageGrading, 10);
var validator = new SaveAiProviderRequestValidator();
Assert.Contains(validator.Validate(cloud).Errors, e => e.PropertyName == "Purposes" && e.ErrorMessage.Contains("Bilder erlaubt"));
Assert.True(validator.Validate(cloud with { AllowImages = true }).IsValid);
Assert.True(validator.Validate(Valid() with { Purposes = AiPurpose.LearningImageGrading }).IsValid); // Heimnetz
}
}
@@ -28,6 +28,7 @@ public sealed class PermissionMatrixTests
{ Permission.EventsEdit, F, F, R, N }, // Kind: nur eigene Termine
{ Permission.FamilyChat, F, F, F, N }, // Gast: nur wenn freigegeben
{ Permission.DirectMessages, F, F, R, N }, // Kind: nur an Familienmitglieder
{ Permission.AiUse, F, F, N, N }, // KI nur für Eltern (ADR-0020)
{ Permission.UserManagement, F, N, N, N },
{ Permission.CalendarManagement, F, N, N, N },
{ Permission.SettingsManagement, F, N, N, N },
@@ -0,0 +1,5 @@
@* Testhülle: MudBlazor-Provider + KI-Verwaltung (Dialoge und Hinweise brauchen sie). *@
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<FamilyPlanner.Web.Client.Components.Admin.AiManagement />
@@ -0,0 +1,274 @@
using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Shared.Ai;
using FamilyPlanner.Web.Client.Components.Admin;
using FamilyPlanner.Web.Client.Services;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace FamilyPlanner.Web.Tests.Admin;
internal sealed class FakeAiApi : IAiApi
{
public List<AiProviderDto> Providers { get; } = [];
public AiUsageOverviewDto Usage { get; set; } = new([], 0, 0);
public AiTestResultDto TestResult { get; set; } = new(true, "Verbindung erfolgreich.", 120, "OK");
public List<SaveAiProviderRequest> Saved { get; } = [];
public List<Guid> Deleted { get; } = [];
public List<Guid> Tested { get; } = [];
public ApiProblem? SaveProblem { get; set; }
private int _version;
public AiProviderDto Seed(string name, AiProviderType type = AiProviderType.Ollama, string url = "http://localhost:11434", AiPurpose purposes = AiPurpose.None,
bool allowImages = false, bool hasKey = false, bool active = true, int limit = 50)
{
var dto = new AiProviderDto(Guid.NewGuid(), name, type, url, "llama3.1", hasKey, 60, active, allowImages, AiRules.IsHomeNetwork(url), purposes, limit, Version());
Providers.Add(dto);
return dto;
}
private byte[] Version() => BitConverter.GetBytes(++_version);
public Task<ApiResult<List<AiProviderDto>>> GetProvidersAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<List<AiProviderDto>>.Ok([.. Providers]));
public Task<ApiResult<AiProviderDto>> CreateProviderAsync(SaveAiProviderRequest request, CancellationToken ct = default)
{
if (SaveProblem is { } problem)
{
return Task.FromResult(ApiResult<AiProviderDto>.Fail(problem));
}
Saved.Add(request);
var url = AiRules.NormalizeBaseUrl(request.Type, request.BaseUrl).Url!;
var dto = new AiProviderDto(Guid.NewGuid(), request.Name, request.Type, url, request.Model, request.ApiKey is not null, request.TimeoutSeconds, request.IsActive,
request.AllowImages, AiRules.IsHomeNetwork(url), request.Purposes, request.DailyLimitPerUser, Version());
Providers.Add(dto);
return Task.FromResult(ApiResult<AiProviderDto>.Ok(dto));
}
public Task<ApiResult<AiProviderDto>> UpdateProviderAsync(Guid id, SaveAiProviderRequest request, CancellationToken ct = default)
{
if (SaveProblem is { } problem)
{
return Task.FromResult(ApiResult<AiProviderDto>.Fail(problem));
}
Saved.Add(request);
var index = Providers.FindIndex(p => p.Id == id);
var url = AiRules.NormalizeBaseUrl(request.Type, request.BaseUrl).Url!;
Providers[index] = Providers[index] with
{
Name = request.Name, Type = request.Type, BaseUrl = url, Model = request.Model, TimeoutSeconds = request.TimeoutSeconds, IsActive = request.IsActive,
AllowImages = request.AllowImages, Purposes = request.Purposes, DailyLimitPerUser = request.DailyLimitPerUser, IsHomeNetwork = AiRules.IsHomeNetwork(url),
HasApiKey = request.ClearApiKey ? false : request.ApiKey is not null || Providers[index].HasApiKey, Version = Version(),
};
return Task.FromResult(ApiResult<AiProviderDto>.Ok(Providers[index]));
}
public Task<ApiResult<bool>> DeleteProviderAsync(Guid id, CancellationToken ct = default)
{
Deleted.Add(id);
Providers.RemoveAll(p => p.Id == id);
return Task.FromResult(ApiResult<bool>.Ok(true));
}
public Task<ApiResult<AiTestResultDto>> TestProviderAsync(Guid id, CancellationToken ct = default)
{
Tested.Add(id);
return Task.FromResult(ApiResult<AiTestResultDto>.Ok(TestResult));
}
public Task<ApiResult<AiUsageOverviewDto>> GetUsageAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<AiUsageOverviewDto>.Ok(Usage));
public Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<AiStatusDto>.Ok(new AiStatusDto(true, true, false)));
}
public sealed class AiManagementTests : BunitContext
{
private readonly FakeAiApi _api = new();
public AiManagementTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
Services.AddSingleton<IAiApi>(_api);
}
private IRenderedComponent<AiManagementHost> Render() => Render<AiManagementHost>();
[Fact]
public void Empty_state_explains_that_ai_features_stay_hidden()
{
var cut = Render();
Assert.Contains("Ohne Anbieter sind alle KI-Funktionen ausgeblendet", cut.Find(".fp-ai-empty").TextContent);
Assert.Contains("Noch keine KI-Aufrufe", cut.Find(".fp-ai-nousage").TextContent);
}
[Fact]
public void Providers_show_privacy_status_purposes_and_limits()
{
_api.Seed("Ollama Keller", purposes: AiPurpose.Default | AiPurpose.LearningImageGrading);
_api.Seed("Claude", AiProviderType.Claude, "https://api.anthropic.com", AiPurpose.LearningGeneration, hasKey: true, limit: 0);
_api.Seed("Alt", active: false);
var cut = Render();
var rows = cut.FindAll(".fp-ai-table tbody tr");
Assert.Equal(3, rows.Count);
Assert.Contains("Heimnetz", rows[0].QuerySelector(".fp-ai-home")!.TextContent);
Assert.Contains("Allgemein, Fotos von Lösungen prüfen", rows[0].TextContent);
Assert.Contains("Bilder erlaubt", rows[0].TextContent);
Assert.Contains("kein Schlüssel", rows[0].TextContent);
Assert.Contains("50/Tag", rows[0].TextContent);
Assert.Contains("Cloud", rows[1].QuerySelector(".fp-ai-cloud")!.TextContent);
Assert.Contains("Bilder gesperrt", rows[1].TextContent);
Assert.Contains("Schlüssel gesetzt", rows[1].TextContent);
Assert.Contains("ohne Tageslimit", rows[1].TextContent);
Assert.Contains("Lernaufgaben erzeugen", rows[1].TextContent);
Assert.Contains("inaktiv", rows[2].QuerySelector(".fp-ai-inactive")!.TextContent);
Assert.DoesNotContain("sk-", cut.Markup);
}
[Fact]
public void Connection_test_shows_the_result_and_refreshes_the_usage()
{
var provider = _api.Seed("Ollama Keller");
var cut = Render();
cut.Find(".fp-ai-test").Click();
cut.WaitForAssertion(() => Assert.Contains("Verbindung erfolgreich", cut.Find(".fp-ai-testresult").TextContent));
Assert.Contains("Ollama Keller", cut.Find(".fp-ai-testresult").TextContent);
Assert.Contains("120 ms", cut.Find(".fp-ai-testresult").TextContent);
Assert.Equal([provider.Id], _api.Tested);
_api.TestResult = new AiTestResultDto(false, "Der KI-Anbieter ist nicht erreichbar.", 0, null);
cut.Find(".fp-ai-test").Click();
cut.WaitForAssertion(() => Assert.Contains("nicht erreichbar", cut.Find(".fp-ai-testresult").TextContent));
Assert.Contains("mud-alert-text-error", cut.Find(".fp-ai-testresult").InnerHtml + cut.Find(".fp-ai-testresult").ClassName);
}
[Fact]
public void Usage_overview_lists_calls_with_tokens_and_failures()
{
_api.Seed("Ollama");
_api.Usage = new AiUsageOverviewDto(
[
new(new DateTimeOffset(2026, 10, 12, 8, 0, 0, TimeSpan.Zero), "Mama", "Ollama", "llama3.1", AiPurpose.LearningGeneration, true, null, 850, 120, 340, false),
new(new DateTimeOffset(2026, 10, 12, 7, 0, 0, TimeSpan.Zero), "Papa", "Ollama", "llama3.1", AiPurpose.LearningImageGrading, false, "Timeout", 60000, null, null, true),
],
2,
1);
var cut = Render();
Assert.Contains("Heute: 2 Aufrufe, davon 1 fehlgeschlagen", cut.Find(".fp-ai-today").TextContent);
var rows = cut.FindAll(".fp-ai-usage tbody tr");
Assert.Contains("OK · 850 ms · 120→340 Tokens", rows[0].TextContent);
Assert.Contains("Lernaufgaben erzeugen", rows[0].TextContent);
Assert.Contains("Fehler: Timeout", rows[1].QuerySelector(".fp-ai-failed")!.TextContent);
Assert.Contains("mit Bild", rows[1].TextContent);
}
[Fact]
public void Create_dialog_applies_type_defaults_and_saves_a_valid_request()
{
var cut = Render();
cut.Find(".fp-add-ai").Click();
cut.WaitForElement("#ai-provider-form");
cut.Find("#ai-name").Change("Ollama Keller");
Assert.Equal("llama3.1", cut.Find("#ai-model").GetAttribute("value"));
cut.Find("#ai-provider-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.Saved));
var saved = _api.Saved.Single();
Assert.Equal(("Ollama Keller", AiProviderType.Ollama, null, "llama3.1"), (saved.Name, saved.Type, saved.BaseUrl, saved.Model));
Assert.Equal((60, 50, true, false, AiPurpose.None), (saved.TimeoutSeconds, saved.DailyLimitPerUser, saved.IsActive, saved.AllowImages, saved.Purposes));
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-ai-table tbody tr")));
}
[Fact]
public void Create_dialog_validates_locally_and_shows_server_problems()
{
var cut = Render();
cut.Find(".fp-add-ai").Click();
cut.WaitForElement("#ai-provider-form");
cut.Find("#ai-provider-form").Submit(); // Name fehlt
cut.WaitForAssertion(() => Assert.Contains("Bitte einen Namen eingeben", cut.Markup));
Assert.Empty(_api.Saved);
cut.Find("#ai-name").Change("Test");
cut.Find("#ai-url").Change("http://169.254.169.254");
cut.Find("#ai-provider-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("nicht erlaubt", cut.Markup));
Assert.Empty(_api.Saved);
cut.Find("#ai-url").Change(string.Empty);
_api.SaveProblem = new ApiProblem { Status = 400, Errors = new() { ["ApiKey"] = ["Für diesen Anbieter ist ein API-Schlüssel nötig."] } };
cut.Find("#ai-provider-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("API-Schlüssel nötig", cut.Markup));
}
[Fact]
public void Edit_dialog_keeps_the_key_unless_replaced_or_removed_and_never_shows_it()
{
var provider = _api.Seed("Claude", AiProviderType.Claude, "https://api.anthropic.com", hasKey: true);
var cut = Render();
cut.Find("button[aria-label='Claude bearbeiten']").Click();
cut.WaitForElement("#ai-provider-form");
Assert.Equal(string.Empty, cut.Find("#ai-key").GetAttribute("value") ?? string.Empty);
Assert.Contains("leer lassen = unverändert", cut.Markup);
Assert.NotNull(cut.Find(".fp-ai-cloudwarning"));
cut.Find("#ai-model").Change("claude-haiku-5-5");
cut.Find("#ai-provider-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.Saved));
var kept = _api.Saved.Single();
Assert.Equal((null, false, "claude-haiku-5-5"), (kept.ApiKey, kept.ClearApiKey, kept.Model));
Assert.Equal(provider.Version.Length, kept.Version!.Length); // Versionsprüfung wird mitgeschickt
Assert.True(_api.Providers[0].HasApiKey);
}
[Fact]
public void Image_opt_in_warning_depends_on_the_address()
{
var cut = Render();
cut.Find(".fp-add-ai").Click();
cut.WaitForElement("#ai-provider-form");
Assert.Empty(cut.FindAll(".fp-ai-cloudwarning")); // Standard: Ollama lokal
cut.Find("#ai-url").Change("https://ollama.example.com");
cut.WaitForAssertion(() => Assert.NotNull(cut.Find(".fp-ai-cloudwarning")));
cut.Find("#ai-url").Change("http://nas.local:11434");
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-ai-cloudwarning")));
}
[Fact]
public void Delete_asks_for_confirmation()
{
var provider = _api.Seed("Weg damit");
var cut = Render();
cut.Find("button[aria-label='Weg damit löschen']").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("button[aria-label='Weg damit löschen']").Click();
cut.WaitForElement(".mud-message-box");
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Löschen")).Click();
cut.WaitForAssertion(() => Assert.Contains("Noch kein Anbieter", cut.Markup));
Assert.Equal([provider.Id], _api.Deleted);
}
}