using System.IO.Compression; using System.Text; using System.Text.Json; using GerbilManagerWebAPI.Export; using GerbilManagerWebAPI.Models; using Microsoft.Extensions.DependencyInjection; namespace GerbilManager.Tests { /// /// EXPORT-1: CSV-Escaping-Units + voller Round-Trip über GET /export /// (seed -> Zip herunterladen -> Einträge parsen -> Zähler + Beispielzeile). /// public class ExportTests { // ── CSV-Escaping ─────────────────────────────────────────────────── [Theory] [InlineData(null, "")] [InlineData("", "")] [InlineData("Krümel", "Krümel")] // Umlaute bleiben unquotiert erhalten [InlineData("a;b", "\"a;b\"")] // Semikolon = Trennzeichen -> quoten [InlineData("sagt \"hallo\"", "\"sagt \"\"hallo\"\"\"")] // Anführungszeichen verdoppeln [InlineData("Zeile1\nZeile2", "\"Zeile1\nZeile2\"")] // Zeilenumbruch -> quoten [InlineData("Meier, Hans", "Meier, Hans")] // Komma ist KEIN Trennzeichen (Semikolon-CSV) public void Escape_behandelt_Sonderfälle(string? input, string expected) => Assert.Equal(expected, ExportService.Escape(input)); [Fact] public void BuildCsv_schreibt_Kopfzeile_und_quotierte_Zeilen() { var csv = ExportService.BuildCsv( ["Name", "Notizen"], [["Krümel", "frisst; gerne \"Hirse\""], ["Bo", null]]); var lines = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); Assert.Equal("Name;Notizen", lines[0]); Assert.Equal("Krümel;\"frisst; gerne \"\"Hirse\"\"\"", lines[1]); Assert.Equal("Bo;", lines[2]); } // ── Round-Trip über die API ──────────────────────────────────────── [Fact] public async Task Export_liefert_Zip_mit_allen_Einträgen_und_korrekten_Daten() { using var factory = new ApiFactory(); // Seed: Kontakt, Becken, Wurf, Tier (mit Bezügen), Gesundheit, Gewicht Guid gerbilId; using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var contact = new Contact { Id = Guid.NewGuid(), Name = "Zoohandlung; \"Meier\"", Email = "meier@example.de" }; var enclosure = new Enclosure { Id = Guid.NewGuid(), Name = "Großbecken" }; var litter = new Litter { Id = Guid.NewGuid(), Name = "Wurf K", Date = new DateOnly(2025, 3, 12), TotalBorn = 5 }; var colorVariety = db.ColorVarieties.First(); // HasData-Seed var gerbil = new Gerbil { Id = Guid.NewGuid(), Name = "Krümel", Gender = Gender.female, Status = GerbilStatus.Breeding, DateOfBirth = new DateOnly(2025, 3, 12), LitterId = litter.Id, EnclosureId = enclosure.Id, OriginContactId = contact.Id, ColorVarietyId = colorVariety.Id, Genotype = "Aa CC Dd EE GG Pp Spsp rere", ImportSource = "Stammbaum von Akio Kids.xlsx", }; gerbilId = gerbil.Id; db.AddRange(contact, enclosure, litter, gerbil, new HealthRecord { Id = Guid.NewGuid(), GerbilId = gerbil.Id, Date = new DateOnly(2026, 1, 15), Type = HealthRecordType.Vaccination, Description = "Jahresimpfung", CreatedAt = DateTimeOffset.UtcNow, }, new WeightRecord { Id = Guid.NewGuid(), GerbilId = gerbil.Id, Date = new DateOnly(2026, 5, 1), WeightGrams = 78, }); await db.SaveChangesAsync(); } var client = factory.CreateClient(); var response = await client.GetAsync("/export"); response.EnsureSuccessStatusCode(); Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType); Assert.Contains("rennmaus-export-", response.Content.Headers.ContentDisposition?.FileName); using var zip = new ZipArchive(await response.Content.ReadAsStreamAsync(), ZipArchiveMode.Read); string[] expectedEntries = ["export.json", "tiere.csv", "wuerfe.csv", "kontakte.csv", "gesundheit.csv", "gewichte.csv", "LIESMICH.txt"]; foreach (var name in expectedEntries) Assert.NotNull(zip.GetEntry(name)); // export.json: Zähler + volle Treue (Genotyp, Import-Herkunft) using var json = JsonDocument.Parse(ReadEntry(zip, "export.json", out _)); var root = json.RootElement; Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength()); Assert.Equal(1, root.GetProperty("litters").GetArrayLength()); Assert.Equal(1, root.GetProperty("contacts").GetArrayLength()); Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 60); // HasData-Seed (61 nach GEN-3f) var g = root.GetProperty("gerbils")[0]; Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString()); Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString()); Assert.Equal(gerbilId.ToString(), g.GetProperty("id").GetString()); // tiere.csv: BOM, deutsche Kopfzeile, aufgelöste Namen + deutsches Datum var tiere = ReadEntry(zip, "tiere.csv", out var hadBom); Assert.True(hadBom, "tiere.csv braucht ein UTF-8-BOM für Excel"); var lines = tiere.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); Assert.StartsWith("Name;Geschlecht;Status;Geburtsdatum", lines[0]); var row = lines[1]; Assert.Contains("Krümel", row); Assert.Contains("Weiblich", row); Assert.Contains("Zucht", row); Assert.Contains("12.03.2025", row); Assert.Contains("Großbecken", row); Assert.Contains("Wurf K", row); // kontakte.csv: Escaping im Ernstfall (Semikolon + Anführungszeichen im Namen) var kontakte = ReadEntry(zip, "kontakte.csv", out _); Assert.Contains("\"Zoohandlung; \"\"Meier\"\"\"", kontakte); // LIESMICH erklärt den Foto-Speicherort Assert.Contains("photo-storage", ReadEntry(zip, "LIESMICH.txt", out _)); } private static string ReadEntry(ZipArchive zip, string name, out bool hadBom) { using var stream = zip.GetEntry(name)!.Open(); using var ms = new MemoryStream(); stream.CopyTo(ms); var bytes = ms.ToArray(); hadBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF; return Encoding.UTF8.GetString(bytes, hadBom ? 3 : 0, bytes.Length - (hadBom ? 3 : 0)); } } }