Merge feature/export-1: Datenexport (zip JSON+German CSVs) on /einstellungen + CI flake-cap [god-QA pending result-gate]

# Conflicts:
#	GerbilManager.Tests/GerbilManager.Tests.csproj
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/src/App.tsx
#	gerbil-manager-web/src/components/AppShell.tsx
#	gerbil-manager-web/src/pages/EinstellungenPage.tsx
This commit is contained in:
2026-06-06 08:06:38 +02:00
10 changed files with 488 additions and 3 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));
}
}
}