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