From fd28b2a954e3fff360dbd7c7076e346421bd0452 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 10:36:02 +0200 Subject: [PATCH] INBOX-2: 8 Tests - Stripping, Prompt-Privacy (keine Mailadresse zum Anbieter), 503, Endpoint-Round-Trip mit Stub (Entwurf gespeichert+geliefert); ApiFactory.ConfigureTestServices-Hook (init-Property, xUnit-fixturefest) --- GerbilManager.Tests/ApiFactory.cs | 9 + GerbilManager.Tests/InboxDraftTests.cs | 217 +++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 GerbilManager.Tests/InboxDraftTests.cs diff --git a/GerbilManager.Tests/ApiFactory.cs b/GerbilManager.Tests/ApiFactory.cs index 98c1167..94aa793 100644 --- a/GerbilManager.Tests/ApiFactory.cs +++ b/GerbilManager.Tests/ApiFactory.cs @@ -19,6 +19,13 @@ public sealed class ApiFactory : WebApplicationFactory { private readonly SqliteConnection _connection = new("DataSource=:memory:"); + /// + /// INBOX-2: Test-spezifische DI-Überschreibungen (z. B. AI-Stub-Handler). + /// Init-Property statt Konstruktor — xUnit-Klassen-Fixtures erlauben nur + /// EINEN öffentlichen (parameterlosen) Konstruktor. + /// + public Action? ConfigureTestServices { get; init; } + public string ContractRoot { get; } = Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}"); @@ -46,6 +53,8 @@ public sealed class ApiFactory : WebApplicationFactory using var provider = services.BuildServiceProvider(); using var scope = provider.CreateScope(); scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + + ConfigureTestServices?.Invoke(services); }); } diff --git a/GerbilManager.Tests/InboxDraftTests.cs b/GerbilManager.Tests/InboxDraftTests.cs new file mode 100644 index 0000000..7f2fc31 --- /dev/null +++ b/GerbilManager.Tests/InboxDraftTests.cs @@ -0,0 +1,217 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using GerbilManagerWebAPI.Inbox; +using GerbilManagerWebAPI.Models; +using GerbilManagerWebAPI.SaleAd; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace GerbilManager.Tests +{ + /// + /// INBOX-2: KI-Antwortentwurf — Prompt-Assembly (inkl. Datenminimierung + + /// Zitat-/Signatur-Stripping), 503-unkonfiguriert, und der Endpoint-Round-Trip + /// gegen einen Stub-Anbieter (Entwurf wird gespeichert UND zurückgegeben). + /// + public class InboxDraftTests + { + private static Request SampleRequest() => new() + { + Id = Guid.NewGuid(), + GmailMessageId = $"<{Guid.NewGuid():N}@mail.example>", + FromAddress = "anna.musterfrau@example.de", + FromName = "Anna Musterfrau", + Subject = "Anfrage: zwei Weibchen?", + BodyText = "Hallo! Habt ihr aktuell zwei junge Weibchen zur Abgabe?\nViele Grüße, Anna", + ReceivedAt = DateTimeOffset.UtcNow, + }; + + // ── Zitat-/Signatur-Stripping ────────────────────────────────────── + + [Fact] + public void StripQuotedText_entfernt_Zitate_Signatur_und_Outlook_Header() + { + var body = string.Join('\n', + "Hallo, habt ihr Tiere abzugeben?", + "> alte zitierte Zeile", + "Von: jemand@example.de", + "Danke!", + "-- ", + "Anna Musterfrau", + "Musterweg 1"); + var stripped = DraftReplyService.StripQuotedText(body); + + Assert.Contains("Hallo, habt ihr Tiere abzugeben?", stripped); + Assert.Contains("Danke!", stripped); + Assert.DoesNotContain("alte zitierte Zeile", stripped); + Assert.DoesNotContain("jemand@example.de", stripped); + Assert.DoesNotContain("Musterweg 1", stripped); // Signatur weg + } + + [Fact] + public void StripQuotedText_schneidet_beim_Gmail_Zitat_Intro_ab() + { + var body = "Meine Frage steht oben.\nAm 05.06.2026 um 10:00 schrieb Zucht der kleinen Chaoten:\n> früherer Verlauf"; + var stripped = DraftReplyService.StripQuotedText(body); + + Assert.Equal("Meine Frage steht oben.", stripped); + } + + // ── Prompt-Assembly + Datenminimierung ───────────────────────────── + + [Fact] + public void UserPrompt_enthält_Anfrage_und_Abgabeliste_aber_keine_Mailadresse() + { + var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(), + [new DraftReplyService.ForSaleAnimal("Frieda", "Gold"), + new DraftReplyService.ForSaleAnimal("Fine", null)]); + + Assert.Contains("Von: Anna Musterfrau", prompt); + Assert.Contains("Betreff: Anfrage: zwei Weibchen?", prompt); + Assert.Contains("zwei junge Weibchen zur Abgabe", prompt); + Assert.Contains("- Frieda (Gold)", prompt); + Assert.Contains("- Fine", prompt); + // Datenminimierung: die E-Mail-Adresse der Absenderin geht NICHT zum Anbieter + Assert.DoesNotContain("anna.musterfrau@example.de", prompt); + } + + [Fact] + public void UserPrompt_ohne_Abgabetiere_enthält_keine_Liste() + { + var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(), []); + Assert.DoesNotContain("abzugebende Tiere", prompt); + } + + [Fact] + public void SystemPrompt_verlangt_Entwurf_ohne_Preise_und_ohne_erfundene_Fakten() + { + var prompt = DraftReplyService.BuildSystemPrompt(); + Assert.Contains("ENTWERFEN", prompt); + Assert.Contains("KEINE Fakten erfinden", prompt); + Assert.Contains("Keine Preise", prompt); + Assert.Contains("keine Adressen", prompt); + } + + // ── 503: unkonfiguriert (Service-Ebene) ──────────────────────────── + + [Fact] + public async Task DraftAsync_meldet_NotConfigured_ohne_AI_Konfiguration() + { + var service = new DraftReplyService( + new HttpClient(new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden"))), + Options.Create(new AiOptions())); + + var result = await service.DraftAsync(SampleRequest(), []); + + Assert.Equal(GerbilManagerWebAPI.Ai.AiCallStatus.NotConfigured, result.Status); + } + + // ── Endpoint-Round-Trip ──────────────────────────────────────────── + + [Fact] + public async Task Draft_Endpoint_meldet_503_AiKeyMissing_wenn_unkonfiguriert() + { + using var factory = new ApiFactory(); + var id = await SeedRequestAsync(factory); + + var response = await factory.CreateClient().PostAsync($"/api/requests/{id}/draft", null); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Contains("AiKeyMissing", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Draft_Endpoint_speichert_und_liefert_den_Entwurf_des_Stubs() + { + const string canned = "Hallo Anna,\n\nschön, dass du fragst — aktuell suchen Frieda und Fine ein Zuhause.\n\nHerzliche Grüße"; + var stub = new StubHandler(_ => Canned(canned)); + using var factory = new ApiFactory + { + ConfigureTestServices = services => + { + services.PostConfigure(o => + { + o.BaseUrl = "https://api.example.com/v1"; + o.ApiKey = "test"; + o.Model = "test-model"; + }); + services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => stub); + }, + }; + + var id = await SeedRequestAsync(factory, alsoForSaleGerbil: true); + var client = factory.CreateClient(); + + var response = await client.PostAsync($"/api/requests/{id}/draft", null); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var dto = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal(canned, dto.RootElement.GetProperty("draftReply").GetString()); + + // Persistiert: erneutes GET liefert den Entwurf + var again = await client.GetFromJsonAsync($"/api/requests/{id}"); + Assert.Equal(canned, again.GetProperty("draftReply").GetString()); + + // Wire-Privacy: Anfragetext + ForSale-Tier gingen zum Anbieter, die + // Mailadresse der Absenderin NICHT. + Assert.NotNull(stub.LastRequestBody); + Assert.Contains("zwei junge Weibchen", stub.LastRequestBody); + Assert.Contains("Aki", stub.LastRequestBody); + Assert.DoesNotContain("anna.musterfrau@example.de", stub.LastRequestBody); + } + + // ── Helfer ───────────────────────────────────────────────────────── + + private static async Task SeedRequestAsync(ApiFactory factory, bool alsoForSaleGerbil = false) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var request = SampleRequest(); + db.Add(request); + if (alsoForSaleGerbil) + { + db.Add(new Gerbil + { + Id = Guid.NewGuid(), + Name = "Aki", + Gender = Gender.female, + Status = GerbilStatus.ForSale, + }); + } + await db.SaveChangesAsync(); + return request.Id; + } + + 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 (Variante von SaleAdTests, hier mit Body-Capture). + private sealed class StubHandler(Func respond) + : HttpMessageHandler + { + public string? LastRequestBody { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + return respond(request); + } + } + } +}