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: verbatim listings from kleine-chaoten.jimdofree.com
Assert.Contains("Status: LOCKER RESERVIERT LEONIE", prompt);
Assert.Contains("Status: FREI", prompt);
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
}
// ── FEAT-14c: Charakterbogen (Traits + Notiz) im Prompt ────────────
[Fact]
public void UserPrompt_enthält_Traits_und_Charakternotiz()
{
var request = new SaleAdRequest(
[new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "alte Verwaltungsnotiz",
Traits: ["zutraulich", "buddelt gern"],
CharacterNote: "klettert abends auf die Hand")],
"FREI", "");
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
Assert.Contains("Charakter: zutraulich, buddelt gern", prompt);
Assert.Contains("Charakter-Notiz: klettert abends auf die Hand", prompt);
}
[Fact]
public void UserPrompt_bevorzugt_Charakterbogen_vor_generischen_Notizen()
{
// Traits vorhanden -> die generische Notiz (oft Verwaltungs-Info)
// gehört NICHT in den Prompt.
var request = new SaleAdRequest(
[new SaleAdAnimal("Balu", null, null, "Käfig wurde am 3.5. gereinigt",
Traits: ["neugierig"])],
"FREI", "");
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
Assert.Contains("Charakter: neugierig", prompt);
Assert.DoesNotContain("Notizen:", prompt);
Assert.DoesNotContain("Käfig wurde", prompt);
}
[Fact]
public void UserPrompt_fällt_ohne_Charakterbogen_auf_Notizen_zurück()
{
// Kein Charakterbogen (null bzw. leer) -> Notizen wie bisher.
var request = new SaleAdRequest(
[
new SaleAdAnimal("Mysti", null, null, "sehr verschmust", Traits: null),
new SaleAdAnimal("Bo", null, null, "mag Kolbenhirse", Traits: []),
],
"FREI", "");
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
Assert.Contains("Notizen: sehr verschmust", prompt);
Assert.Contains("Notizen: mag Kolbenhirse", prompt);
Assert.DoesNotContain("Charakter:", prompt);
}
[Fact]
public void SystemPrompt_verlangt_fließende_Prosa_statt_Stichwortliste()
{
var prompt = SaleAdPromptBuilder.BuildSystemPrompt();
Assert.Contains("Charakter-Eigenschaften", prompt);
Assert.Contains("niemals als Aufzählung", 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);
}
}
}
}