- NameSuggestionService: Gemini via existing OpenAiChatClient, builds
letter/gender/usages/count prompt, strips markdown fences from response,
deserialises [{name,meaning,origin}] array; NotConfigured -> 503.
- GET /names/suggest?letter=&gender=&usages=&count= -> Ok<List<NameSuggestion>>
| 503 {code:NamesKeyMissing} | 502 {code:NamesUpstreamError}.
- Litter.LitterLetter (string?, nullable) + AddLitterLetter migration.
- 17 new tests (prompt assembly, fence strip, parse edge cases,
503-not-configured, upstream-error); total 157/157 green.
- No Behind-the-Name dependency — Gemini path only (Julian's decision).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
209 lines
9.0 KiB
C#
209 lines
9.0 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using GerbilManagerWebAPI.Names;
|
|
using GerbilManagerWebAPI.SaleAd;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace GerbilManager.Tests
|
|
{
|
|
/// <summary>
|
|
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
|
|
/// fence strip), 503-not-configured path, upstream-error path.
|
|
/// </summary>
|
|
public class NameSuggestionTests
|
|
{
|
|
// ── Prompt assembly ───────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
|
|
{
|
|
var prompt = NameSuggestionService.BuildSystemPrompt();
|
|
Assert.Contains("reinen JSON-Array", prompt);
|
|
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
|
|
Assert.Contains("name", prompt);
|
|
Assert.Contains("meaning", prompt);
|
|
Assert.Contains("origin", prompt);
|
|
}
|
|
|
|
[Fact]
|
|
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
|
{
|
|
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
|
|
Assert.Contains("6", prompt);
|
|
Assert.Contains("\"A\"", prompt);
|
|
Assert.Contains("weibliche", prompt);
|
|
Assert.Contains("norn,mythg", prompt);
|
|
}
|
|
|
|
[Fact]
|
|
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
|
|
{
|
|
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
|
|
Assert.Contains("5", prompt);
|
|
Assert.DoesNotContain("Buchstaben", prompt);
|
|
Assert.DoesNotContain("weibliche", prompt);
|
|
Assert.DoesNotContain("männliche", prompt);
|
|
}
|
|
|
|
[Fact]
|
|
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
|
|
{
|
|
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
|
|
Assert.DoesNotContain("weibliche", prompt);
|
|
Assert.DoesNotContain("männliche", prompt);
|
|
}
|
|
|
|
// ── JSON parsing ──────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void ParseSuggestions_verarbeitet_reines_JSON()
|
|
{
|
|
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
|
|
var result = NameSuggestionService.ParseSuggestions(json);
|
|
Assert.NotNull(result);
|
|
Assert.Single(result);
|
|
Assert.Equal("Astrid", result[0].Name);
|
|
Assert.Equal("göttliche Stärke", result[0].Meaning);
|
|
Assert.Equal("Altnordisch", result[0].Origin);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseSuggestions_strippt_json_Markdown_Fence()
|
|
{
|
|
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
|
|
var result = NameSuggestionService.ParseSuggestions(fenced);
|
|
Assert.NotNull(result);
|
|
Assert.Equal("Aiko", result![0].Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseSuggestions_strippt_generische_Markdown_Fence()
|
|
{
|
|
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
|
var result = NameSuggestionService.ParseSuggestions(fenced);
|
|
Assert.NotNull(result);
|
|
Assert.Equal("Luna", result![0].Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
|
|
{
|
|
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
|
|
var result = NameSuggestionService.ParseSuggestions(messy);
|
|
Assert.NotNull(result);
|
|
Assert.Equal("Sol", result![0].Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
|
|
{
|
|
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
|
|
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
|
|
Assert.Null(NameSuggestionService.ParseSuggestions(""));
|
|
}
|
|
|
|
// ── 503: nicht konfiguriert ───────────────────────────────────────────
|
|
|
|
[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 SuggestAsync_gibt_NotConfigured_wenn_AI_Key_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.SuggestAsync(null, null, null, 5);
|
|
|
|
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
|
|
Assert.Null(result.Suggestions);
|
|
}
|
|
|
|
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task SuggestAsync_parst_valide_JSON_Antwort()
|
|
{
|
|
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
|
|
var handler = new StubHandler(_ => Canned(payload));
|
|
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
|
|
|
|
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
|
|
|
|
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
|
Assert.NotNull(result.Suggestions);
|
|
Assert.Equal(2, result.Suggestions!.Count);
|
|
Assert.Equal("Astrid", result.Suggestions[0].Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
|
|
{
|
|
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
|
var handler = new StubHandler(_ => Canned(fenced));
|
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
|
|
|
var result = await service.SuggestAsync(null, null, null, 1);
|
|
|
|
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
|
Assert.Equal("Luna", result.Suggestions![0].Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
|
|
{
|
|
var handler = new StubHandler(_ => Canned("das ist kein json"));
|
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
|
|
|
var result = await service.SuggestAsync(null, null, null, 3);
|
|
|
|
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
|
Assert.Null(result.Suggestions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
|
|
{
|
|
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
|
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
|
|
|
var result = await service.SuggestAsync(null, null, null, 3);
|
|
|
|
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
|
}
|
|
|
|
// ── Helfer ────────────────────────────────────────────────────────────
|
|
|
|
private static NameSuggestionService CreateService(
|
|
string? baseUrl, string? apiKey, string? model, StubHandler handler)
|
|
{
|
|
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
|
|
return new NameSuggestionService(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"),
|
|
};
|
|
}
|
|
|
|
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
|
: HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
=> Task.FromResult(respond(request));
|
|
}
|
|
}
|
|
}
|