From cbe77c927a61d15af3eb91ca7113e3e430dab184 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:09:07 +0200 Subject: [PATCH] FEAT-13: ContractGenerator (pure OpenXml, embedded template, per-animal table cloning) + BreederProfile/ContractData records + 10 xUnit tests incl. schema validation & PII guard Co-Authored-By: Claude Opus 4.8 (1M context) --- GerbilManager.Tests/ContractGeneratorTests.cs | 224 ++++++++++++++++++ .../Contracts/BreederProfile.cs | 29 +++ GerbilManagerWebAPI/Contracts/ContractData.cs | 34 +++ .../Contracts/ContractGenerator.cs | 152 ++++++++++++ .../GerbilManagerWebAPI.csproj | 6 + 5 files changed, 445 insertions(+) create mode 100644 GerbilManager.Tests/ContractGeneratorTests.cs create mode 100644 GerbilManagerWebAPI/Contracts/BreederProfile.cs create mode 100644 GerbilManagerWebAPI/Contracts/ContractData.cs create mode 100644 GerbilManagerWebAPI/Contracts/ContractGenerator.cs diff --git a/GerbilManager.Tests/ContractGeneratorTests.cs b/GerbilManager.Tests/ContractGeneratorTests.cs new file mode 100644 index 0000000..0d5567b --- /dev/null +++ b/GerbilManager.Tests/ContractGeneratorTests.cs @@ -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; + +/// +/// 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. +/// +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)); + + /// + /// 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). + /// + 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(() => 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); + } +} diff --git a/GerbilManagerWebAPI/Contracts/BreederProfile.cs b/GerbilManagerWebAPI/Contracts/BreederProfile.cs new file mode 100644 index 0000000..dacbd59 --- /dev/null +++ b/GerbilManagerWebAPI/Contracts/BreederProfile.cs @@ -0,0 +1,29 @@ +namespace GerbilManagerWebAPI.Contracts; + +/// +/// Zuchtprofil — der parameterisierte Verkäufer-Block des Abgabevertrags. +/// Phase A: wird aus der appsettings-Sektion "BreederProfile" gebunden +/// (z. B. builder.Configuration.GetSection("BreederProfile").Get<BreederProfile>()); +/// eine Settings-UI/Entity folgt in Phase B. Die Vorlage selbst enthält KEINE +/// echten Daten (nur {{Platzhalter}}) — Werte kommen ausschließlich von hier. +/// +public sealed class BreederProfile +{ + /// Name der Zucht, z. B. „Zucht der Kleinen Chaoten“. + public string ZuchtName { get; set; } = ""; + + /// Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“. + public string Name { get; set; } = ""; + + /// Anschrift einzeilig: „Straße Nr, PLZ Ort“. + public string Address { get; set; } = ""; + + public string Phone { get; set; } = ""; + + public string Email { get; set; } = ""; + + public string Homepage { get; set; } = ""; + + /// Ort für die Unterschriftszeile („{Ort}, den {Datum}“). + public string City { get; set; } = ""; +} diff --git a/GerbilManagerWebAPI/Contracts/ContractData.cs b/GerbilManagerWebAPI/Contracts/ContractData.cs new file mode 100644 index 0000000..3c19e97 --- /dev/null +++ b/GerbilManagerWebAPI/Contracts/ContractData.cs @@ -0,0 +1,34 @@ +namespace GerbilManagerWebAPI.Contracts; + +/// Käufer/Abnehmer-Block des Abgabevertrags. +/// Vor- und Nachname inkl. Anrede, z. B. „Herr Max Muster“. +/// Anschrift einzeilig: „Straße Nr, PLZ Ort“. +public sealed record ContractBuyer( + string Name, + string Address, + string? Phone = null, + string? Email = null); + +/// Ein abgegebenes Tier (eine Tabelle im Vertrag pro Tier). +/// Deutscher Anzeigetext („Weiblich“/„Männlich“) — die +/// Abbildung vom Gender-Enum passiert im Aufrufer (Phase B), der +/// Generator bleibt frei von Modell-Abhängigkeiten. +public sealed record ContractAnimal( + string Name, + string Geschlecht, + DateOnly? Geburtsdatum = null, + string? Farbschlag = null); + +/// +/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen. +/// +/// Kaufpreis in Euro; gerendert als de-DE, z. B. „72,00 €“. +/// Übergabedatum (Abschnitt 3), Format TT.MM.JJJJ. +/// Datum der Unterschriftszeile; Standard = Übergabedatum. +public sealed record ContractData( + BreederProfile Seller, + ContractBuyer Buyer, + IReadOnlyList Animals, + decimal Price, + DateOnly HandoverDate, + DateOnly? ContractDate = null); diff --git a/GerbilManagerWebAPI/Contracts/ContractGenerator.cs b/GerbilManagerWebAPI/Contracts/ContractGenerator.cs new file mode 100644 index 0000000..05d931e --- /dev/null +++ b/GerbilManagerWebAPI/Contracts/ContractGenerator.cs @@ -0,0 +1,152 @@ +using System.Globalization; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; + +namespace GerbilManagerWebAPI.Contracts; + +/// +/// FEAT-13: Erzeugt Abgabeverträge (.docx) aus der eingebetteten Vorlage +/// (Contracts/Templates/Abgabevertrag.docx, abgeleitet aus dem echten +/// Mustervertrag; Boilerplate-Abschnitte 4–8 stehen verbatim in der Vorlage). +/// +/// Reiner Dienst: keine EF-/HTTP-Abhängigkeiten — Eingabe ist ein +/// , 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). +/// +public static class ContractGenerator +{ + private const string TemplateResource = "GerbilManagerWebAPI.Contracts.Templates.Abgabevertrag.docx"; + + private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + + /// Erzeugt den Vertrag mit der eingebetteten Standard-Vorlage. + public static byte[] Generate(ContractData data) => Generate(data, LoadEmbeddedTemplate()); + + /// Erzeugt den Vertrag mit einer expliziten Vorlage (Tests/Sonderfälle). + 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(); + } + + /// + /// 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. + /// + private static void FillAnimalTables(Body body, IReadOnlyList animals) + { + var templateTable = body.Descendants() + .Single(t => t.InnerText.Contains("{{TierName}}")); + + var tables = new List
{ 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 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 AnimalTokens(ContractAnimal animal) => new() + { + ["{{TierName}}"] = animal.Name, + ["{{TierGeschlecht}}"] = animal.Geschlecht, + ["{{TierGeburtsdatum}}"] = animal.Geburtsdatum is { } born ? FormatDate(born) : "", + ["{{TierFarbschlag}}"] = animal.Farbschlag ?? "", + }; + + /// z. B. 72m → „72,00 €“, 1234.5m → „1.234,50 €“. + private static string FormatPrice(decimal price) => price.ToString("N2", German) + " €"; + + /// TT.MM.JJJJ (deutsche Schreibweise, wie im Mustervertrag). + private static string FormatDate(DateOnly date) => date.ToString("dd.MM.yyyy", German); + + /// + /// Ersetzt Tokens in allen Text-Runs unterhalb von . + /// Tokens liegen in der Vorlage garantiert in einzelnen Runs — kein + /// Run-übergreifendes Matching nötig. + /// + private static void ReplaceTokens(OpenXmlElement root, IReadOnlyDictionary tokens) + { + foreach (var text in root.Descendants()) + { + 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(); + } +} diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj index da3989d..af6567e 100644 --- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj +++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj @@ -8,6 +8,7 @@ + @@ -24,4 +25,9 @@ + + + + +