Merge feature/feat-13: Abgabevertrag generator phase A (template + pure OpenXml generator + 10 tests) [god-QA: 18/18]

This commit is contained in:
2026-06-06 07:11:30 +02:00
5 changed files with 445 additions and 0 deletions

View File

@@ -0,0 +1,224 @@
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Validation;
using GerbilManagerWebAPI.Contracts;
namespace GerbilManager.Tests;
/// <summary>
/// FEAT-13: Tests des Abgabevertrag-Generators. Die erzeugte .docx wird als
/// Zip geöffnet und auf word/document.xml geprüft — kein Word nötig.
/// </summary>
public class ContractGeneratorTests
{
private static BreederProfile Seller() => new()
{
ZuchtName = "Zucht Testhausen",
Name = "Frau Erika Muster",
Address = "Musterweg 1, 12345 Testhausen",
Phone = "0123 456789",
Email = "zucht@example.org",
Homepage = "https://zucht.example.org/",
City = "Testhausen",
};
private static ContractAnimal Krümel() => new(
Name: "Krümel",
Geschlecht: "Weiblich",
Geburtsdatum: new DateOnly(2025, 3, 9),
Farbschlag: "Agouti");
private static ContractData OneAnimal() => new(
Seller: Seller(),
Buyer: new ContractBuyer(
Name: "Herr Max Beispiel",
Address: "Beispielallee 7, 54321 Beispielstadt",
Phone: "0987 654321",
Email: "max@example.com"),
Animals: [Krümel()],
Price: 72m,
HandoverDate: new DateOnly(2026, 6, 5));
/// <summary>
/// Sichtbarer Text aus word/document.xml der erzeugten Datei. Tags werden
/// entfernt, damit Aussagen über Run-Grenzen hinweg möglich sind (Werte
/// und Fließtext liegen in getrennten w:t-Runs).
/// </summary>
private static string DocumentText(byte[] docx)
{
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
var entry = Assert.Single(zip.Entries, e => e.FullName == "word/document.xml");
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
return Regex.Replace(reader.ReadToEnd(), "<[^>]+>", "");
}
[Fact]
public void EinTier_EnthaeltAlleVertragswerte()
{
var text = DocumentText(ContractGenerator.Generate(OneAnimal()));
// Verkäufer-Block (aus dem Zuchtprofil)
Assert.Contains("Zucht Testhausen", text);
Assert.Contains("Frau Erika Muster", text);
Assert.Contains("Musterweg 1, 12345 Testhausen", text);
Assert.Contains("0123 456789", text);
Assert.Contains("zucht@example.org", text);
Assert.Contains("https://zucht.example.org/", text);
// Käufer-Block
Assert.Contains("Herr Max Beispiel", text);
Assert.Contains("Beispielallee 7, 54321 Beispielstadt", text);
Assert.Contains("0987 654321", text);
Assert.Contains("max@example.com", text);
// Tier
Assert.Contains("Krümel", text);
Assert.Contains("Weiblich", text);
Assert.Contains("09.03.2025", text);
Assert.Contains("Agouti", text);
// Kaufpreis (de-DE) + Übergabedatum (TT.MM.JJJJ)
Assert.Contains("Kaufpreis von 72,00 €", text);
Assert.Contains("am 05.06.2026 dem Käufer", text);
// Unterschriftszeile: Ort des Züchters + Vertragsdatum (= Übergabedatum)
Assert.Contains("Testhausen, den 05.06.2026", text);
}
[Fact]
public void KeineUnersetztenTokens()
{
var text = DocumentText(ContractGenerator.Generate(OneAnimal()));
Assert.DoesNotContain("{{", text);
Assert.DoesNotContain("}}", text);
}
[Fact]
public void DreiTiere_KlontDieTierTabelleProTier()
{
var data = OneAnimal() with
{
Animals =
[
Krümel(),
new ContractAnimal("Fridolin", "Männlich", new DateOnly(2023, 5, 1), "Schwarz"),
new ContractAnimal("Luna", "Weiblich", new DateOnly(2023, 8, 15), "Gold"),
],
};
var text = DocumentText(ContractGenerator.Generate(data));
Assert.Contains("Krümel", text);
Assert.Contains("Fridolin", text);
Assert.Contains("Luna", text);
Assert.Contains("01.05.2023", text);
Assert.Contains("15.08.2023", text);
// Pro Tier eine Tabelle: „Tierart:“ ist das fixe Label jeder Tier-Tabelle.
Assert.Equal(3, Regex.Matches(text, "Tierart:").Count);
Assert.DoesNotContain("{{", text);
}
[Fact]
public void PreisUndDatum_DeutscheFormate()
{
var data = OneAnimal() with { Price = 1234.5m, HandoverDate = new DateOnly(2026, 1, 3) };
var text = DocumentText(ContractGenerator.Generate(data));
Assert.Contains("Kaufpreis von 1.234,50 €", text);
Assert.Contains("am 03.01.2026 dem Käufer", text);
}
[Fact]
public void VertragsDatum_UeberschreibtUebergabedatumInDerUnterschriftszeile()
{
var data = OneAnimal() with { ContractDate = new DateOnly(2026, 6, 7) };
var text = DocumentText(ContractGenerator.Generate(data));
Assert.Contains("Testhausen, den 07.06.2026", text);
Assert.Contains("am 05.06.2026 dem Käufer", text); // Übergabedatum bleibt eigenständig
}
[Fact]
public void OptionaleFelder_LeerStattPlatzhalter()
{
var data = OneAnimal() with
{
Buyer = new ContractBuyer("Frau Lisa Test", "Testgasse 2, 11111 Teststadt"),
Animals = [new ContractAnimal("Momo", "Unbekannt")],
};
var text = DocumentText(ContractGenerator.Generate(data));
Assert.Contains("Frau Lisa Test", text);
Assert.Contains("Momo", text);
Assert.DoesNotContain("{{", text);
Assert.DoesNotContain("null", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void OhneTiere_WirftArgumentException()
{
var data = OneAnimal() with { Animals = [] };
var ex = Assert.Throws<ArgumentException>(() => ContractGenerator.Generate(data));
Assert.Contains("mindestens ein Tier", ex.Message);
}
[Fact]
public void ErzeugteDatei_IstEinGueltigesDocxPaket()
{
var docx = ContractGenerator.Generate(OneAnimal());
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
Assert.Contains(zip.Entries, e => e.FullName == "[Content_Types].xml");
Assert.Contains(zip.Entries, e => e.FullName == "word/document.xml");
// Tierfotos wurden beim Vorlagenbau entfernt, das Zucht-Logo bleibt.
Assert.Contains(zip.Entries, e => e.FullName == "word/media/image1.jpeg");
Assert.DoesNotContain(zip.Entries, e => e.FullName == "word/media/image2.jpeg");
}
[Fact]
public void ErzeugtesDokument_ValidiertGegenOpenXmlSchema()
{
// Office-2013-Schema: die Vorlage nutzt w:tblLook-Attribute der
// Word-2010-Form, die der 2007-Default des Validators nicht kennt.
var validator = new OpenXmlValidator(DocumentFormat.OpenXml.FileFormatVersions.Office2013);
var data = OneAnimal() with
{
Animals = [Krümel(), new ContractAnimal("Fridolin", "Männlich"), new ContractAnimal("Luna", "Weiblich")],
};
using var generatedDoc = WordprocessingDocument.Open(
new MemoryStream(ContractGenerator.Generate(data)), isEditable: false);
var errors = validator.Validate(generatedDoc)
.Select(e => $"{e.ErrorType}: {e.Description} [{e.Path?.XPath}]")
.ToList();
Assert.True(errors.Count == 0, "OpenXml-Validierungsfehler:\n" + string.Join("\n", errors));
}
[Fact]
public void VorlageSelbst_EnthaeltKeineEchtdaten()
{
// Die Vorlage wird hier über den Generator-Pfad geladen: ein Vertrag
// mit leeren Werten darf keinerlei Daten des Mustervertrags enthalten.
var data = new ContractData(
new BreederProfile(),
new ContractBuyer("", ""),
[new ContractAnimal("", "")],
0m,
new DateOnly(2026, 1, 1));
var text = DocumentText(ContractGenerator.Generate(data));
// Stichproben der Original-PII (Name/Ort/Mail des Mustervertrags).
Assert.DoesNotContain("Nießner", text);
Assert.DoesNotContain("Hartengrund", text);
Assert.DoesNotContain("Ronneburg", text);
Assert.DoesNotContain("Schädtler", text);
Assert.DoesNotContain("gmx.de", text);
Assert.DoesNotContain("jimdofree", text);
}
}

View File

@@ -0,0 +1,29 @@
namespace GerbilManagerWebAPI.Contracts;
/// <summary>
/// Zuchtprofil — der parameterisierte Verkäufer-Block des Abgabevertrags.
/// Phase A: wird aus der appsettings-Sektion <c>"BreederProfile"</c> gebunden
/// (z. B. <c>builder.Configuration.GetSection("BreederProfile").Get&lt;BreederProfile&gt;()</c>);
/// eine Settings-UI/Entity folgt in Phase B. Die Vorlage selbst enthält KEINE
/// echten Daten (nur {{Platzhalter}}) — Werte kommen ausschließlich von hier.
/// </summary>
public sealed class BreederProfile
{
/// <summary>Name der Zucht, z. B. „Zucht der Kleinen Chaoten“.</summary>
public string ZuchtName { get; set; } = "";
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
public string Name { get; set; } = "";
/// <summary>Anschrift einzeilig: „Straße Nr, PLZ Ort“.</summary>
public string Address { get; set; } = "";
public string Phone { get; set; } = "";
public string Email { get; set; } = "";
public string Homepage { get; set; } = "";
/// <summary>Ort für die Unterschriftszeile („{Ort}, den {Datum}“).</summary>
public string City { get; set; } = "";
}

View File

@@ -0,0 +1,34 @@
namespace GerbilManagerWebAPI.Contracts;
/// <summary>Käufer/Abnehmer-Block des Abgabevertrags.</summary>
/// <param name="Name">Vor- und Nachname inkl. Anrede, z. B. „Herr Max Muster“.</param>
/// <param name="Address">Anschrift einzeilig: „Straße Nr, PLZ Ort“.</param>
public sealed record ContractBuyer(
string Name,
string Address,
string? Phone = null,
string? Email = null);
/// <summary>Ein abgegebenes Tier (eine Tabelle im Vertrag pro Tier).</summary>
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
public sealed record ContractAnimal(
string Name,
string Geschlecht,
DateOnly? Geburtsdatum = null,
string? Farbschlag = null);
/// <summary>
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
/// </summary>
/// <param name="Price">Kaufpreis in Euro; gerendert als de-DE, z. B. „72,00 €“.</param>
/// <param name="HandoverDate">Übergabedatum (Abschnitt 3), Format TT.MM.JJJJ.</param>
/// <param name="ContractDate">Datum der Unterschriftszeile; Standard = Übergabedatum.</param>
public sealed record ContractData(
BreederProfile Seller,
ContractBuyer Buyer,
IReadOnlyList<ContractAnimal> Animals,
decimal Price,
DateOnly HandoverDate,
DateOnly? ContractDate = null);

View File

@@ -0,0 +1,152 @@
using System.Globalization;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace GerbilManagerWebAPI.Contracts;
/// <summary>
/// FEAT-13: Erzeugt Abgabeverträge (.docx) aus der eingebetteten Vorlage
/// (<c>Contracts/Templates/Abgabevertrag.docx</c>, abgeleitet aus dem echten
/// Mustervertrag; Boilerplate-Abschnitte 48 stehen verbatim in der Vorlage).
///
/// Reiner Dienst: keine EF-/HTTP-Abhängigkeiten — Eingabe ist ein
/// <see cref="ContractData"/>, Ausgabe sind die fertigen .docx-Bytes.
/// Die Vorlage enthält pro Wert genau EINEN {{Token}}-Run (beim Vorlagenbau
/// zusammengeführt), darum genügt einfache Textersetzung; die Tier-Tabelle
/// wird pro Tier geklont (variable Anzahl 1..n).
/// </summary>
public static class ContractGenerator
{
private const string TemplateResource = "GerbilManagerWebAPI.Contracts.Templates.Abgabevertrag.docx";
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
/// <summary>Erzeugt den Vertrag mit der eingebetteten Standard-Vorlage.</summary>
public static byte[] Generate(ContractData data) => Generate(data, LoadEmbeddedTemplate());
/// <summary>Erzeugt den Vertrag mit einer expliziten Vorlage (Tests/Sonderfälle).</summary>
public static byte[] Generate(ContractData data, byte[] template)
{
ArgumentNullException.ThrowIfNull(data);
if (data.Animals.Count == 0)
{
throw new ArgumentException("Ein Abgabevertrag braucht mindestens ein Tier.", nameof(data));
}
using var stream = new MemoryStream();
stream.Write(template);
using (var document = WordprocessingDocument.Open(stream, isEditable: true))
{
var body = document.MainDocumentPart?.Document.Body
?? throw new InvalidOperationException("Vorlage ohne Dokumentrumpf.");
FillAnimalTables(body, data.Animals);
ReplaceTokens(body, GlobalTokens(data));
document.MainDocumentPart!.Document.Save();
}
return stream.ToArray();
}
/// <summary>
/// Die Vorlage enthält genau eine Tier-Tabelle (mit {{TierName}}). Für
/// jedes weitere Tier wird sie samt Abstands-Absatz geklont; danach wird
/// jede Tabelle mit den Werten „ihres“ Tieres gefüllt.
/// </summary>
private static void FillAnimalTables(Body body, IReadOnlyList<ContractAnimal> animals)
{
var templateTable = body.Descendants<Table>()
.Single(t => t.InnerText.Contains("{{TierName}}"));
var tables = new List<Table> { templateTable };
// Abstands-Absatz hinter der Tabelle (Optik wie im Original-Mehrtier-Vertrag).
OpenXmlElement anchor = templateTable.NextSibling() is Paragraph spacer
? spacer
: templateTable;
for (var i = 1; i < animals.Count; i++)
{
var clone = (Table)templateTable.CloneNode(deep: true);
anchor = anchor.InsertAfterSelf(clone);
anchor = anchor.InsertAfterSelf(new Paragraph());
tables.Add(clone);
}
for (var i = 0; i < animals.Count; i++)
{
ReplaceTokens(tables[i], AnimalTokens(animals[i]));
}
}
private static Dictionary<string, string> GlobalTokens(ContractData data) => new()
{
["{{ZuchtName}}"] = data.Seller.ZuchtName,
["{{VerkaeuferName}}"] = data.Seller.Name,
["{{VerkaeuferAdresse}}"] = data.Seller.Address,
["{{VerkaeuferTelefon}}"] = data.Seller.Phone,
["{{VerkaeuferEmail}}"] = data.Seller.Email,
["{{VerkaeuferHomepage}}"] = data.Seller.Homepage,
["{{KaeuferName}}"] = data.Buyer.Name,
["{{KaeuferAdresse}}"] = data.Buyer.Address,
["{{KaeuferTelefon}}"] = data.Buyer.Phone ?? "",
["{{KaeuferEmail}}"] = data.Buyer.Email ?? "",
["{{Kaufpreis}}"] = FormatPrice(data.Price),
["{{Uebergabedatum}}"] = FormatDate(data.HandoverDate),
["{{VertragsOrt}}"] = data.Seller.City,
["{{VertragsDatum}}"] = FormatDate(data.ContractDate ?? data.HandoverDate),
};
private static Dictionary<string, string> AnimalTokens(ContractAnimal animal) => new()
{
["{{TierName}}"] = animal.Name,
["{{TierGeschlecht}}"] = animal.Geschlecht,
["{{TierGeburtsdatum}}"] = animal.Geburtsdatum is { } born ? FormatDate(born) : "",
["{{TierFarbschlag}}"] = animal.Farbschlag ?? "",
};
/// <summary>z. B. 72m → „72,00 €“, 1234.5m → „1.234,50 €“.</summary>
private static string FormatPrice(decimal price) => price.ToString("N2", German) + " €";
/// <summary>TT.MM.JJJJ (deutsche Schreibweise, wie im Mustervertrag).</summary>
private static string FormatDate(DateOnly date) => date.ToString("dd.MM.yyyy", German);
/// <summary>
/// Ersetzt Tokens in allen Text-Runs unterhalb von <paramref name="root"/>.
/// Tokens liegen in der Vorlage garantiert in einzelnen Runs — kein
/// Run-übergreifendes Matching nötig.
/// </summary>
private static void ReplaceTokens(OpenXmlElement root, IReadOnlyDictionary<string, string> tokens)
{
foreach (var text in root.Descendants<Text>())
{
if (!text.Text.Contains("{{"))
{
continue;
}
foreach (var (token, value) in tokens)
{
if (text.Text.Contains(token))
{
text.Text = text.Text.Replace(token, value);
}
}
if (text.Text.Length > 0 && (char.IsWhiteSpace(text.Text[0]) || char.IsWhiteSpace(text.Text[^1])))
{
text.Space = SpaceProcessingModeValues.Preserve;
}
}
}
private static byte[] LoadEmbeddedTemplate()
{
using var resource = typeof(ContractGenerator).Assembly.GetManifestResourceStream(TemplateResource)
?? throw new InvalidOperationException($"Eingebettete Vorlage fehlt: {TemplateResource}");
using var buffer = new MemoryStream();
resource.CopyTo(buffer);
return buffer.ToArray();
}
}

View File

@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
@@ -24,4 +25,9 @@
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
</ItemGroup>
</Project>