diff --git a/GerbilManager.Tests/ExportTests.cs b/GerbilManager.Tests/ExportTests.cs new file mode 100644 index 0000000..3e56061 --- /dev/null +++ b/GerbilManager.Tests/ExportTests.cs @@ -0,0 +1,144 @@ +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.Active, + 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() >= 70); // HasData-Seed + 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("Aktiv", 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)); + } + } +} diff --git a/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs new file mode 100644 index 0000000..7f0ea52 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs @@ -0,0 +1,36 @@ +using GerbilManagerWebAPI.Export; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// EXPORT-1: GET /export — komplette Datensicherung als Zip + /// (export.json voll, CSVs deutsch/Excel-freundlich, LIESMICH.txt; OHNE Fotos). + /// + public static class ExportEndpoints + { + public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/export", async (ApplicationContext db, CancellationToken ct) => + { + var data = new ExportService.ExportData( + await db.Gerbils.AsNoTracking().ToListAsync(ct), + await db.Litters.AsNoTracking().ToListAsync(ct), + await db.Contacts.AsNoTracking().ToListAsync(ct), + await db.Enclosures.AsNoTracking().ToListAsync(ct), + await db.ColorVarieties.AsNoTracking().ToListAsync(ct), + await db.HealthRecords.AsNoTracking().ToListAsync(ct), + await db.WeightRecords.AsNoTracking().ToListAsync(ct), + await db.GerbilPhotos.AsNoTracking().ToListAsync(ct)); + + var today = DateOnly.FromDateTime(DateTime.Now); + var bytes = ExportService.BuildZip(data, today); + return Results.File(bytes, "application/zip", + $"rennmaus-export-{today:yyyy-MM-dd}.zip"); + }) + .WithTags("Export"); + + return app; + } + } +} diff --git a/GerbilManagerWebAPI/Export/ExportService.cs b/GerbilManagerWebAPI/Export/ExportService.cs new file mode 100644 index 0000000..9f83ff5 --- /dev/null +++ b/GerbilManagerWebAPI/Export/ExportService.cs @@ -0,0 +1,212 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using GerbilManagerWebAPI.Models; + +namespace GerbilManagerWebAPI.Export +{ + /// + /// EXPORT-1: user-facing Datenexport — builds a zip containing + /// - export.json (full fidelity: every entity incl. genotypes + import provenance) + /// - *.csv (Tiere, Würfe, Kontakte, Gesundheit, Gewichte — German headers, + /// de-DE formats, semicolon-separated, UTF-8 with BOM for Excel) + /// - LIESMICH.txt (German explainer; notes that photos live on disk, not in the zip) + /// + /// Pure function over (no DbContext) so the round-trip + /// and escaping tests run without a database. + /// + public static class ExportService + { + public sealed record ExportData( + List Gerbils, + List Litters, + List Contacts, + List Enclosures, + List ColorVarieties, + List HealthRecords, + List WeightRecords, + List Photos); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + WriteIndented = true, + ReferenceHandler = ReferenceHandler.IgnoreCycles, + }; + + // German labels for enum values (CSV is the wife-facing backup format; + // the JSON keeps the enum member names for lossless re-import). + private static readonly Dictionary GenderDe = new() + { + [Gender.unknown] = "Unbekannt", + [Gender.male] = "Männlich", + [Gender.female] = "Weiblich", + }; + + private static readonly Dictionary StatusDe = new() + { + [GerbilStatus.Active] = "Aktiv", + [GerbilStatus.Deceased] = "Verstorben", + [GerbilStatus.GivenAway] = "Abgegeben", + }; + + private static readonly Dictionary HealthTypeDe = new() + { + [HealthRecordType.Examination] = "Untersuchung", + [HealthRecordType.Treatment] = "Behandlung", + [HealthRecordType.Injury] = "Verletzung", + [HealthRecordType.Vaccination] = "Impfung", + [HealthRecordType.Other] = "Sonstiges", + }; + + public static byte[] BuildZip(ExportData data, DateOnly exportDate) + { + var gerbilName = data.Gerbils.ToDictionary(g => g.Id, g => g.Name); + var litterName = data.Litters.ToDictionary(l => l.Id, l => l.Name); + var contactName = data.Contacts.ToDictionary(c => c.Id, c => c.Name); + var enclosureName = data.Enclosures.ToDictionary(e => e.Id, e => e.Name); + var colorName = data.ColorVarieties.ToDictionary(c => c.Id, c => c.Name); + + string? Lookup(Dictionary map, TKey? key) where TKey : struct => + key is null ? null : map.GetValueOrDefault(key.Value); + + using var stream = new MemoryStream(); + using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + AddText(zip, "export.json", JsonSerializer.Serialize(new + { + exportedAt = exportDate.ToString("yyyy-MM-dd"), + data.Gerbils, + data.Litters, + data.Contacts, + data.Enclosures, + data.ColorVarieties, + data.HealthRecords, + data.WeightRecords, + data.Photos, + }, JsonOptions), bom: false); + + AddCsv(zip, "tiere.csv", + ["Name", "Geschlecht", "Status", "Geburtsdatum", "Todesdatum", "Todesursache", + "Abgabedatum", "Farbschlag", "Becken", "Wurf", "Herkunft", "Abnehmer", + "Genotyp", "Notizen"], + data.Gerbils.OrderBy(g => g.Name).Select(g => new[] + { + g.Name, GenderDe[g.Gender], StatusDe[g.Status], + De(g.DateOfBirth), De(g.DateOfDeath), g.CauseOfDeath, + De(g.GoHomeDate), Lookup(colorName, g.ColorVarietyId), + Lookup(enclosureName, g.EnclosureId), Lookup(litterName, g.LitterId), + Lookup(contactName, g.OriginContactId), Lookup(contactName, g.ReceiverContactId), + g.Genotype, g.Notes, + })); + + AddCsv(zip, "wuerfe.csv", + ["Bezeichnung", "Wurfdatum", "Vater", "Mutter", "Wurfstärke", + "Voraussichtliches Abgabedatum", "Notizen"], + data.Litters.OrderBy(l => l.Date).Select(l => new[] + { + l.Name, De(l.Date), Lookup(gerbilName, l.FatherId), Lookup(gerbilName, l.MotherId), + l.TotalBorn?.ToString(CultureInfo.InvariantCulture), + De(l.ExpectedGoHomeDate), l.Notes, + })); + + AddCsv(zip, "kontakte.csv", + ["Name", "E-Mail", "Telefon", "Adresse", "Notizen"], + data.Contacts.OrderBy(c => c.Name).Select(c => new[] + { + c.Name, c.Email, c.Phone, c.Address, c.Notes, + })); + + AddCsv(zip, "gesundheit.csv", + ["Tier", "Datum", "Art", "Beschreibung", "Tierarzt"], + data.HealthRecords.OrderBy(h => h.Date).Select(h => new[] + { + gerbilName.GetValueOrDefault(h.GerbilId), De(h.Date), + HealthTypeDe[h.Type], h.Description, h.Veterinarian, + })); + + AddCsv(zip, "gewichte.csv", + ["Tier", "Datum", "Gewicht (g)", "Notizen"], + data.WeightRecords.OrderBy(w => w.Date).Select(w => new[] + { + gerbilName.GetValueOrDefault(w.GerbilId), De(w.Date), + w.WeightGrams.ToString(CultureInfo.InvariantCulture), w.Notes, + })); + + AddText(zip, "LIESMICH.txt", $""" + Rennmaus-Manager — Datenexport vom {exportDate.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)} + + Enthalten: + - export.json : vollständige Datensicherung (alle Tiere, Würfe, Kontakte, + Becken, Farbschläge, Gesundheits- und Gewichtseinträge, + Foto-Verzeichnis, inkl. Genotypen und Import-Herkunft). + - tiere.csv : alle Tiere als Tabelle (für Excel/LibreOffice). + - wuerfe.csv : alle Würfe. + - kontakte.csv : alle Kontakte. + - gesundheit.csv: alle Gesundheitseinträge. + - gewichte.csv : alle Gewichtseinträge. + + Die CSV-Dateien sind mit Semikolon getrennt und öffnen sich in einem + deutschen Excel per Doppelklick. Datumsangaben im Format TT.MM.JJJJ. + + FOTOS sind aus Platzgründen NICHT im Export enthalten. Sie liegen als + normale Bilddateien im Datenordner der Anwendung (Ordner "photo-storage" + neben der API bzw. der in Photos:RootPath konfigurierte Pfad) und können + von dort direkt kopiert/gesichert werden. Die Zuordnung Foto -> Tier + steht in export.json (Abschnitt "photos"). + """, bom: false); + } + return stream.ToArray(); + } + + // ── CSV building blocks ──────────────────────────────────────────── + + /// "2024-03-12" -> "12.03.2024"; null -> empty. + private static string? De(DateOnly? date) => + date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture); + + private static string De(DateOnly date) => + date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture); + + /// + /// Semicolon-separated (German Excel default), CRLF rows, fields quoted when + /// they contain separator/quote/newline; quotes doubled. + /// + internal static string BuildCsv(string[] header, IEnumerable rows) + { + var sb = new StringBuilder(); + sb.Append(string.Join(';', header.Select(Escape))).Append("\r\n"); + foreach (var row in rows) + sb.Append(string.Join(';', row.Select(Escape))).Append("\r\n"); + return sb.ToString(); + } + + internal static string Escape(string? field) + { + if (string.IsNullOrEmpty(field)) return ""; + return field.Contains(';') || field.Contains('"') || field.Contains('\n') || field.Contains('\r') + ? $"\"{field.Replace("\"", "\"\"")}\"" + : field; + } + + private static void AddCsv(ZipArchive zip, string name, string[] header, + IEnumerable rows) => + AddText(zip, name, BuildCsv(header, rows), bom: true); + + private static void AddText(ZipArchive zip, string name, string content, bool bom) + { + var entry = zip.CreateEntry(name, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + if (bom) + { + // UTF-8 BOM: ohne ihn zeigt ein deutsches Excel Umlaute kaputt an. + entryStream.Write(Encoding.UTF8.GetPreamble()); + } + var bytes = Encoding.UTF8.GetBytes(content); + entryStream.Write(bytes); + } + } +} diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj index af6567e..cd572d9 100644 --- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj +++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj @@ -30,4 +30,9 @@ + + + + + diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 897d765..da1a4a6 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -64,6 +64,7 @@ app.MapInbreedingEndpoints(); app.MapPhotoEndpoints(); app.MapContractEndpoints(); app.MapSettingsEndpoints(); +app.MapExportEndpoints(); app.Run();