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:
144
GerbilManager.Tests/ExportTests.cs
Normal file
144
GerbilManager.Tests/ExportTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs
Normal file
36
GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
212
GerbilManagerWebAPI/Export/ExportService.cs
Normal file
212
GerbilManagerWebAPI/Export/ExportService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ app.MapSaleAdEndpoints();
|
|||||||
app.MapImportEndpoints();
|
app.MapImportEndpoints();
|
||||||
app.MapContractEndpoints();
|
app.MapContractEndpoints();
|
||||||
app.MapSettingsEndpoints();
|
app.MapSettingsEndpoints();
|
||||||
|
app.MapExportEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
20
gerbil-manager-web/e2e/einstellungen.spec.ts
Normal file
20
gerbil-manager-web/e2e/einstellungen.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/** EXPORT-1: Einstellungen — Datenexport-Karte + Download-Smoke. */
|
||||||
|
import { de, expect, gotoSection, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.datenexport
|
||||||
|
|
||||||
|
test('Einstellungen zeigt die Datenexport-Karte und der Download startet', async ({ page }) => {
|
||||||
|
await gotoSection(page, de.nav.settings)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.einstellungen.title })).toBeVisible()
|
||||||
|
|
||||||
|
// Datenexport-Karte mit deutschem Erklärtext
|
||||||
|
await expect(page.getByRole('heading', { name: t.title })).toBeVisible()
|
||||||
|
await expect(page.getByText(t.intro)).toBeVisible()
|
||||||
|
await expect(page.getByText(t.photoNote)).toBeVisible()
|
||||||
|
|
||||||
|
// Klick startet den Zip-Download
|
||||||
|
const downloadPromise = page.waitForEvent('download')
|
||||||
|
await page.getByRole('link', { name: t.button }).click()
|
||||||
|
const download = await downloadPromise
|
||||||
|
expect(download.suggestedFilename()).toContain('rennmaus-export')
|
||||||
|
})
|
||||||
@@ -98,11 +98,37 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
|
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.route(`${API_ORIGIN}/**`, async (route) => {
|
const handler = async (route: Route) => {
|
||||||
const request = route.request()
|
const request = route.request()
|
||||||
const url = new URL(request.url())
|
const url = new URL(request.url())
|
||||||
const method = request.method()
|
const method = request.method()
|
||||||
const path = url.pathname
|
// OPS-1: in Produktion ist die API-Basis der relative Pfad /api (nginx-Proxy);
|
||||||
|
// den Präfix normalisieren, damit der Mock unter beiden Basen funktioniert.
|
||||||
|
const path = url.pathname.replace(/^\/api(?=\/)/, '')
|
||||||
|
|
||||||
|
// EXPORT-1: Zip-Download (Inhalt egal — der Smoke prüft nur, dass der
|
||||||
|
// Download startet; ein leeres Zip = End-of-central-directory-Record).
|
||||||
|
if (path === '/export' && method === 'GET') {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/zip',
|
||||||
|
headers: { 'Content-Disposition': 'attachment; filename="rennmaus-export-e2e.zip"' },
|
||||||
|
body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FEAT-13: Zuchtprofil (Einstellungen-Seite lädt es vor dem Rendern)
|
||||||
|
if (path === '/settings/breeder-profile') {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return json(route, 200, {
|
||||||
|
zuchtName: 'Zucht der kleinen Chaoten',
|
||||||
|
name: 'Frau Erika Muster',
|
||||||
|
address: 'Musterweg 1, 12345 Musterstadt',
|
||||||
|
phone: '', email: '', homepage: '', city: 'Musterstadt',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (method === 'PUT') return json(route, 204)
|
||||||
|
}
|
||||||
|
|
||||||
// Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge)
|
// Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge)
|
||||||
let m = path.match(/^\/gerbils\/([^/]+)\/photos$/)
|
let m = path.match(/^\/gerbils\/([^/]+)\/photos$/)
|
||||||
@@ -157,7 +183,15 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 204)
|
return json(route, 204)
|
||||||
}
|
}
|
||||||
return json(route, 405)
|
return json(route, 405)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Beide API-Basen abfangen: absolute Dev-URL und /api-relativ (OPS-1-Proxy).
|
||||||
|
// URL-Prädikat statt Glob: '**/api/**' würde auch Vites Modul-Requests
|
||||||
|
// (/src/api/client.ts …) treffen und die App selbst kaputt-intercepten.
|
||||||
|
await page.route(
|
||||||
|
(url) => url.origin === API_ORIGIN || url.pathname.startsWith('/api/'),
|
||||||
|
handler,
|
||||||
|
)
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 1 : 0,
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
// CI-Flake-Schutz (Dwights Beobachtung, 2× 'Kontakt anlegen'): alle Worker
|
||||||
|
// teilen sich EINEN Vite-Dev-Server; unbegrenzte Parallelität erzeugt dort
|
||||||
|
// Timing-Druck (on-demand-Transforms). Lokal bleibt Playwrights Default.
|
||||||
|
workers: process.env.CI ? 4 : undefined,
|
||||||
reporter: [['list']],
|
reporter: [['list']],
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
use: {
|
use: {
|
||||||
|
|||||||
21
gerbil-manager-web/src/components/DatenexportCard.tsx
Normal file
21
gerbil-manager-web/src/components/DatenexportCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* EXPORT-1: Datenexport-Karte (auf /einstellungen) — lädt GET /export als Zip.
|
||||||
|
* Schlichter Anker-Download (kein fetch/Blob nötig); der Server setzt
|
||||||
|
* Content-Disposition mit Datumsdateinamen.
|
||||||
|
*/
|
||||||
|
import { API_BASE_URL } from '../api/client'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
|
export default function DatenexportCard() {
|
||||||
|
const t = de.pages.datenexport
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3>{t.title}</h3>
|
||||||
|
<p className="muted">{t.intro}</p>
|
||||||
|
<p className="muted">{t.photoNote}</p>
|
||||||
|
<a className="btn btn--primary" href={`${API_BASE_URL}/export`} download>
|
||||||
|
{t.button}
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
type BreederProfile,
|
type BreederProfile,
|
||||||
} from '../api/settings'
|
} from '../api/settings'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
// EXPORT-1 (Oscar): Datenexport-Karte
|
||||||
|
import DatenexportCard from '../components/DatenexportCard'
|
||||||
|
|
||||||
export default function EinstellungenPage() {
|
export default function EinstellungenPage() {
|
||||||
const t = de.pages.einstellungen
|
const t = de.pages.einstellungen
|
||||||
@@ -95,6 +97,8 @@ export default function EinstellungenPage() {
|
|||||||
{saved && <span className="muted">{tz.saved}</span>}
|
{saved && <span className="muted">{tz.saved}</span>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<DatenexportCard />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -565,6 +565,15 @@ export const de = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ── EXPORT-1 (Oscar): Datenexport (Karte auf /einstellungen) ──
|
||||||
|
datenexport: {
|
||||||
|
title: 'Datenexport',
|
||||||
|
intro:
|
||||||
|
'Sicherung deiner Daten — alle Tiere, Würfe und Kontakte als Tabellen (CSV für Excel) plus eine vollständige Datensicherung (JSON), gebündelt als Zip.',
|
||||||
|
button: 'Export herunterladen',
|
||||||
|
photoNote:
|
||||||
|
'Fotos sind nicht enthalten — sie liegen als Bilddateien im Datenordner der Anwendung und können von dort gesichert werden.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// ── HELP-1: In-App-Anleitung ──
|
// ── HELP-1: In-App-Anleitung ──
|
||||||
hilfe: {
|
hilfe: {
|
||||||
|
|||||||
Reference in New Issue
Block a user