Files
GerbilManager/GerbilManager.Tests/ExportTests.cs
Gulum 594923e10f GEN-3f backend re-seed: ColorVariety catalog 73 -> 61 (CP- names)
Re-synced SeedColorVarieties from Kevin's merged GEN-3f frontend seed
(colorVarietySeed.generated.json, 61 rows) — verified byte-identical
(name + canonicalGenotype + order). Changes are all in the cchm colourpoint
group: 'Siam (Marder-Hell)' -> 'Siam' (het cchmch), CP suffix names ->
CP- prefix (CP-Agouti/CP-Silberagouti/CP-Algierfuchs/CP-Polarfuchs), new
CP-Fuchs/CP-Fuchs-Hell/CP-Blaufuchs, Zobel-Hell kept (het); 12 zero-
occurrence CP/dd siblings removed.

Migration ReseedColorVarietiesGen3f: Up = 38 UpdateData (rename/remap IN
PLACE on the same deterministic Id — references survive) + 12 DeleteData of
the removed rows (0 occurrences in her data per Kevin; Gerbil.ColorVarietyId
is ON DELETE SET NULL, and the live re-import re-matches Farbschlag BY NAME,
so no orphan/hard-delete risk). No AddColumn. has-pending-model-changes
clean. Updated the export test's seed-count threshold (>=70 -> >=60).

Last of the import-side batch on this branch (band-aware Farbschlag +
conflict-decisions + this re-seed) — ready for the single supervised
re-extract + idempotent re-import. dotnet 121/121 + python green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:59:57 +02:00

145 lines
7.3 KiB
C#

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() >= 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("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));
}
}
}