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.MapContractEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapExportEndpoints();
|
||||
|
||||
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'),
|
||||
}
|
||||
|
||||
await page.route(`${API_ORIGIN}/**`, async (route) => {
|
||||
const handler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
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)
|
||||
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, 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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export default defineConfig({
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
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']],
|
||||
timeout: 30_000,
|
||||
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,
|
||||
} from '../api/settings'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
// EXPORT-1 (Oscar): Datenexport-Karte
|
||||
import DatenexportCard from '../components/DatenexportCard'
|
||||
|
||||
export default function EinstellungenPage() {
|
||||
const t = de.pages.einstellungen
|
||||
@@ -95,6 +97,8 @@ export default function EinstellungenPage() {
|
||||
{saved && <span className="muted">{tz.saved}</span>}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DatenexportCard />
|
||||
</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 ──
|
||||
hilfe: {
|
||||
|
||||
Reference in New Issue
Block a user