Merge feature/inbox-2: POST /api/requests/{id}/draft (AI German reply draft, never auto-send), extract OpenAiChatClient to Ai/, privacy stripping, 8 tests [god-QA pending]

This commit is contained in:
2026-06-06 10:37:56 +02:00
7 changed files with 481 additions and 69 deletions

View File

@@ -19,6 +19,13 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
{
private readonly SqliteConnection _connection = new("DataSource=:memory:");
/// <summary>
/// INBOX-2: Test-spezifische DI-Überschreibungen (z. B. AI-Stub-Handler).
/// Init-Property statt Konstruktor — xUnit-Klassen-Fixtures erlauben nur
/// EINEN öffentlichen (parameterlosen) Konstruktor.
/// </summary>
public Action<IServiceCollection>? 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<Program>
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated();
ConfigureTestServices?.Invoke(services);
});
}

View File

@@ -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
{
/// <summary>
/// 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).
/// </summary>
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<AiOptions>(o =>
{
o.BaseUrl = "https://api.example.com/v1";
o.ApiKey = "test";
o.Model = "test-model";
});
services.AddHttpClient<DraftReplyService>()
.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<JsonElement>($"/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<Guid> SeedRequestAsync(ApiFactory factory, bool alsoForSaleGerbil = false)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
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"),
};
}
/// <summary>HttpMessageHandler-Stub (Variante von SaleAdTests, hier mit Body-Capture).</summary>
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
: HttpMessageHandler
{
public string? LastRequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return respond(request);
}
}
}
}