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