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,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')
})

View File

@@ -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
}

View File

@@ -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: {

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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: {