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 @@
+
+
+
+
+