GerbilStatus: Active→Breeding, add Pet; Deceased/GivenAway are now DERIVED. GerbilStatusService.Derive(): central precedence: DateOfDeath→Deceased (1), ReceiverContactId→GivenAway (2), age>7y→Deceased presumed (3), user choice (4). All write paths (gerbil CRUD, contracts, importers) call GerbilStatusService.Apply(). Startup sweep flips >7y gerbils to Deceased at next app restart. Migration StatusModel: Active→Breeding rename + backfill derived statuses. 10 new tests; 200/200 green; has-pending=No. Enum string values: Breeding (was Active), Pet (new), Deceased, GivenAway, ForSale. FE contract: status field values updated (see Done-Report).
145 lines
7.3 KiB
C#
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.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));
|
|
}
|
|
}
|
|
}
|