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) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 07:09:07 +02:00
parent e4b7558abd
commit cbe77c927a
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);
}
}