EXPORT-1: GET /export - Zip mit export.json (volle Treue) + deutschen Excel-CSVs (Semikolon, BOM, TT.MM.JJJJ) + LIESMICH; 9 Tests (Escaping + API-Round-Trip)

This commit is contained in:
2026-06-06 07:50:42 +02:00
parent 78da534c7c
commit f86a044143
5 changed files with 398 additions and 0 deletions

View File

@@ -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
{
/// <summary>
/// EXPORT-1: CSV-Escaping-Units + voller Round-Trip über GET /export
/// (seed -> Zip herunterladen -> Einträge parsen -> Zähler + Beispielzeile).
/// </summary>
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<ApplicationContext>();
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));
}
}
}

View File

@@ -0,0 +1,36 @@
using GerbilManagerWebAPI.Export;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// EXPORT-1: GET /export — komplette Datensicherung als Zip
/// (export.json voll, CSVs deutsch/Excel-freundlich, LIESMICH.txt; OHNE Fotos).
/// </summary>
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;
}
}
}

View File

@@ -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
{
/// <summary>
/// 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 <see cref="ExportData"/> (no DbContext) so the round-trip
/// and escaping tests run without a database.
/// </summary>
public static class ExportService
{
public sealed record ExportData(
List<Gerbil> Gerbils,
List<Litter> Litters,
List<Contact> Contacts,
List<Enclosure> Enclosures,
List<ColorVariety> ColorVarieties,
List<HealthRecord> HealthRecords,
List<WeightRecord> WeightRecords,
List<GerbilPhoto> 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<Gender, string> GenderDe = new()
{
[Gender.unknown] = "Unbekannt",
[Gender.male] = "Männlich",
[Gender.female] = "Weiblich",
};
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
{
[GerbilStatus.Active] = "Aktiv",
[GerbilStatus.Deceased] = "Verstorben",
[GerbilStatus.GivenAway] = "Abgegeben",
};
private static readonly Dictionary<HealthRecordType, string> 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<TKey>(Dictionary<TKey, string> 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 ────────────────────────────────────────────
/// <summary>"2024-03-12" -> "12.03.2024"; null -> empty.</summary>
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);
/// <summary>
/// Semicolon-separated (German Excel default), CRLF rows, fields quoted when
/// they contain separator/quote/newline; quotes doubled.
/// </summary>
internal static string BuildCsv(string[] header, IEnumerable<string?[]> 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<string?[]> 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);
}
}
}

View File

@@ -30,4 +30,9 @@
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
<InternalsVisibleTo Include="GerbilManager.Tests" />
</ItemGroup>
</Project>

View File

@@ -64,6 +64,7 @@ app.MapInbreedingEndpoints();
app.MapPhotoEndpoints();
app.MapContractEndpoints();
app.MapSettingsEndpoints();
app.MapExportEndpoints();
app.Run();