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); } } } }