diff --git a/GerbilManager.Tests/SaleAdTests.cs b/GerbilManager.Tests/SaleAdTests.cs new file mode 100644 index 0000000..0032df1 --- /dev/null +++ b/GerbilManager.Tests/SaleAdTests.cs @@ -0,0 +1,225 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using GerbilManagerWebAPI.SaleAd; +using Microsoft.Extensions.Options; + +namespace GerbilManager.Tests +{ + /// + /// FEAT-12a: sale-ad backend — prompt assembly, the 503-unconfigured path, + /// the OpenAI-compatible wire handling against a stubbed provider, and the + /// provider config matrix (Gemini / Groq / Mistral / Ollama base URLs). + /// + public class SaleAdTests + { + private static SaleAdRequest SampleRequest() => new( + Animals: + [ + new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "ruhig, nimmt Leckerlis aus der Hand"), + new SaleAdAnimal("Benny", "Schwarz Schecke", "2024-03-12", "neugieriger Entdecker"), + ], + StatusLine: "FREI", + Hints: "bitte die Bruder-Dynamik betonen"); + + // ── Prompt assembly ──────────────────────────────────────────────── + + [Fact] + public void UserPrompt_enthält_Tiere_Status_und_Hinweise() + { + var prompt = SaleAdPromptBuilder.BuildUserPrompt(SampleRequest()); + + Assert.Contains("Status-Zeile: FREI", prompt); + Assert.Contains("Name: Balu", prompt); + Assert.Contains("Farbschlag: CP-Agouti", prompt); + Assert.Contains("Name: Benny", prompt); + Assert.Contains("ruhig, nimmt Leckerlis aus der Hand", prompt); + Assert.Contains("Wünsche/Hinweise: bitte die Bruder-Dynamik betonen", prompt); + } + + [Fact] + public void UserPrompt_formatiert_Geburtsdatum_deutsch() + { + var prompt = SaleAdPromptBuilder.BuildUserPrompt(SampleRequest()); + Assert.Contains("geboren am 12.03.2024", prompt); + Assert.DoesNotContain("2024-03-12", prompt); + } + + [Fact] + public void UserPrompt_lässt_fehlende_Angaben_weg() + { + var request = new SaleAdRequest( + [new SaleAdAnimal("Mysti", null, null, null)], "RESERVIERT", ""); + var prompt = SaleAdPromptBuilder.BuildUserPrompt(request); + + Assert.Contains("Name: Mysti", prompt); + Assert.DoesNotContain("Farbschlag:", prompt); + Assert.DoesNotContain("geboren am", prompt); + Assert.DoesNotContain("Notizen:", prompt); + Assert.DoesNotContain("Wünsche/Hinweise:", prompt); + } + + [Fact] + public void SystemPrompt_enthält_Stilregeln_und_beide_Beispiele() + { + var prompt = SaleAdPromptBuilder.BuildSystemPrompt(); + + Assert.Contains("Zucht der kleinen Chaoten", prompt); + Assert.Contains("NIEMALS Preise", prompt); + Assert.Contains("KEINE Fakten erfinden", prompt); + // few-shot: die beiden Beispiel-Inserate (Status-Zeilen der Vorlage) + Assert.Contains("Status: FREI", prompt); + Assert.Contains("Status: LOCKER RESERVIERT Anna", prompt); + Assert.Contains("Großer und kleiner Bruder Dynamik", prompt); + } + + [Theory] + [InlineData("2024-03-12", "12.03.2024")] + [InlineData("kaputt", "kaputt")] // unparsebar -> unverändert durchreichen + public void FormatGermanDate_konvertiert_ISO_nach_TT_MM_JJJJ(string input, string expected) + => Assert.Equal(expected, SaleAdPromptBuilder.FormatGermanDate(input)); + + // ── 503: unkonfiguriert ──────────────────────────────────────────── + + [Theory] + [InlineData(null, "key", "model")] + [InlineData("https://api.example.com/v1", null, "model")] + [InlineData("https://api.example.com/v1", "key", null)] + [InlineData(null, null, null)] + public async Task GenerateAsync_meldet_NotConfigured_wenn_eine_Einstellung_fehlt( + string? baseUrl, string? apiKey, string? model) + { + var service = CreateService(baseUrl, apiKey, model, + new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden"))); + + var result = await service.GenerateAsync(SampleRequest()); + + Assert.Equal(SaleAdStatus.NotConfigured, result.Status); + Assert.Null(result.Text); + } + + // ── Fake-Server: OpenAI-kompatible Antwort wird geparst ──────────── + + [Fact] + public async Task GenerateAsync_parst_die_Chat_Completion_des_Stubs() + { + var handler = new StubHandler(_ => Canned("Status: FREI\n\nWunderbares Duo …")); + var service = CreateService("https://api.groq.com/openai/v1", "k", "llama-3.3-70b", handler); + + var result = await service.GenerateAsync(SampleRequest()); + + Assert.Equal(SaleAdStatus.Ok, result.Status); + Assert.Equal("Status: FREI\n\nWunderbares Duo …", result.Text); + } + + [Fact] + public async Task GenerateAsync_sendet_Modell_Messages_und_Bearer_Key() + { + var handler = new StubHandler(_ => Canned("ok")); + var service = CreateService("https://api.example.com/v1", "geheim", "test-model", handler); + + await service.GenerateAsync(SampleRequest()); + + Assert.NotNull(handler.LastRequest); + Assert.Equal("Bearer", handler.LastRequest!.Headers.Authorization?.Scheme); + Assert.Equal("geheim", handler.LastRequest.Headers.Authorization?.Parameter); + + using var body = JsonDocument.Parse(handler.LastRequestBody!); + Assert.Equal("test-model", body.RootElement.GetProperty("model").GetString()); + var messages = body.RootElement.GetProperty("messages"); + Assert.Equal(2, messages.GetArrayLength()); + Assert.Equal("system", messages[0].GetProperty("role").GetString()); + Assert.Equal("user", messages[1].GetProperty("role").GetString()); + } + + [Fact] + public async Task GenerateAsync_meldet_UpstreamError_bei_Provider_HTTP_Fehler() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests) + { + Content = new StringContent("rate limited"), + }); + var service = CreateService("https://api.example.com/v1", "k", "m", handler); + + var result = await service.GenerateAsync(SampleRequest()); + + Assert.Equal(SaleAdStatus.UpstreamError, result.Status); + } + + [Fact] + public async Task GenerateAsync_meldet_UpstreamError_bei_leerer_Antwort() + { + var handler = new StubHandler(_ => Canned("")); + var service = CreateService("https://api.example.com/v1", "k", "m", handler); + + var result = await service.GenerateAsync(SampleRequest()); + + Assert.Equal(SaleAdStatus.UpstreamError, result.Status); + } + + // ── Provider-Konfigurationsmatrix: BaseUrl -> /chat/completions ──── + + [Theory] + [InlineData("https://generativelanguage.googleapis.com/v1beta/openai", + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions")] // Gemini + [InlineData("https://api.groq.com/openai/v1", + "https://api.groq.com/openai/v1/chat/completions")] // Groq + [InlineData("https://api.mistral.ai/v1", + "https://api.mistral.ai/v1/chat/completions")] // Mistral + [InlineData("http://truenas:11434/v1", + "http://truenas:11434/v1/chat/completions")] // Ollama (lokal) + [InlineData("https://api.groq.com/openai/v1/", + "https://api.groq.com/openai/v1/chat/completions")] // trailing slash + public async Task Konfigurationsmatrix_baut_die_richtige_Completions_URL( + string baseUrl, string expectedUri) + { + Assert.Equal(new Uri(expectedUri), SaleAdService.BuildCompletionsUri(baseUrl)); + + // und der Stub sieht den Aufruf tatsächlich auf dieser URL + var handler = new StubHandler(_ => Canned("ok")); + var service = CreateService(baseUrl, "k", "m", handler); + await service.GenerateAsync(SampleRequest()); + Assert.Equal(new Uri(expectedUri), handler.LastRequest!.RequestUri); + } + + // ── Helfer ───────────────────────────────────────────────────────── + + private static SaleAdService CreateService( + string? baseUrl, string? apiKey, string? model, StubHandler handler) + { + var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model }); + return new SaleAdService(new HttpClient(handler), options); + } + + private static HttpResponseMessage Canned(string content) + { + var completion = new + { + choices = new[] { new { message = new { role = "assistant", content } } }, + }; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(completion), + Encoding.UTF8, "application/json"), + }; + } + + /// HttpMessageHandler-Stub, der Request + Body zur Inspektion festhält. + private sealed class StubHandler(Func respond) + : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastRequestBody { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + LastRequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + return respond(request); + } + } + } +} diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj index af6567e..cd572d9 100644 --- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj +++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj @@ -30,4 +30,9 @@ + + + + +