feat(daten): export als zip, json und csv sowie import eines archivs

Unter Verwaltung -> Daten laedt der Admin alles als ZIP (Daten plus
hochgeladene Dateien), nur die Daten als JSON oder die Sammlung als CSV
- letzteres mit Semikolon und BOM, damit deutsches Excel es ohne
Rueckfragen oeffnet. Die CSV steht auch Mitgliedern offen, das
vollstaendige Archiv nur Administratoren.

Bewusst nicht im Export: Konten, Passwoerter und Passkeys. Ein Export
voller Passwort-Hashes im Download-Ordner waere ein Risiko und kein
Backup - das bleibt ./data. Ebenfalls draussen: Reservierungen und
Teilen-Links, die zur jeweiligen Instanz gehoeren.

Der Import ersetzt alle Anwendungsdaten in einer Transaktion, statt zu
vermischen: Zusammenfuehren zweier Sammlungen braeuchte Konfliktregeln
fuer jede Tabelle und waere im Ergebnis nicht vorhersagbar. Deshalb ist
er Administratoren vorbehalten und verlangt ein getipptes ERSETZEN.
Wunschlisten gehoeren danach dem importierenden Admin und bekommen
frische, zunaechst nicht geteilte Links. Pfade aus dem Archiv werden
gegen Ausbrueche aus dem Dateiverzeichnis geprueft.

10 Tests decken den Rundweg ab: Export, Loeschen, Import, Ersetzen statt
Vermischen, und dass ein fremdes Archiv abgelehnt wird, ohne etwas zu
loeschen.

Refs Phase 2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-07 07:23:45 +02:00
co-authored by Claude Opus 5
parent 5c5c5213bb
commit 45aa8f13cf
10 changed files with 928 additions and 0 deletions
+2
View File
@@ -23,6 +23,8 @@ next-env.d.ts
/data-shots
/data-docker
/backups
/data-test
/data-test-export
# production
*.tsbuildinfo
+1
View File
@@ -32,6 +32,7 @@
"cn": "^0.2.6",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.45.2",
"fflate": "^0.8.3",
"lucide-react": "^1.41.0",
"next": "15.5.25",
"next-themes": "^0.4.6",
+8
View File
@@ -35,6 +35,9 @@ importers:
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5)
fflate:
specifier: ^0.8.3
version: 0.8.3
lucide-react:
specifier: ^1.41.0
version: 1.41.0(react@19.1.0)
@@ -3348,6 +3351,9 @@ packages:
picomatch:
optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
@@ -8131,6 +8137,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.7
fflate@0.8.3: {}
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
+45
View File
@@ -0,0 +1,45 @@
'use server';
import { revalidatePath } from 'next/cache';
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
import { importArchive, ImportError, type ImportSummary } from '@/lib/export';
import { requireAdmin } from '@/lib/session';
const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
/** Typed by hand before anything is deleted. */
const CONFIRMATION = 'ERSETZEN';
export async function importArchiveAction(
formData: FormData,
): Promise<ActionResult<ImportSummary>> {
const admin = await requireAdmin();
if (formData.get('confirmation') !== CONFIRMATION) {
return actionError(`Bitte tipp ${CONFIRMATION} ein, um den Austausch zu bestätigen.`);
}
const file = formData.get('archive');
if (!(file instanceof File) || file.size === 0) {
return actionError('Bitte wähle ein Bricktrack-Archiv (.zip) aus.');
}
if (file.size > MAX_ARCHIVE_BYTES) {
return actionError('Das Archiv ist größer als 512 MB und wird nicht eingelesen.');
}
try {
const summary = importArchive(
new Uint8Array(await file.arrayBuffer()),
admin.id,
);
revalidatePath('/', 'layout');
return actionData(summary);
} catch (error) {
if (error instanceof ImportError) return actionError(error.message);
console.error('[import] Archiv konnte nicht eingelesen werden', error);
return actionError(
'Das Archiv konnte nicht eingelesen werden. Die vorhandenen Daten wurden nicht verändert.',
);
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import { DataTransfer } from '@/components/verwaltung/data-transfer';
import { requireAdmin } from '@/lib/session';
export const metadata: Metadata = { title: 'Daten' };
export default async function DatenPage() {
await requireAdmin();
return <DataTransfer />;
}
+106
View File
@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server';
import {
buildCollectionCsv,
buildExportPayload,
buildExportZip,
exportFileName,
} from '@/lib/export';
import { formatDate } from '@/lib/format';
import { conditionLabel, completenessLabel, statusLabel } from '@/lib/labels';
import { centsToInput } from '@/lib/money';
import { listCollectionItems } from '@/lib/queries/collection';
import { getCurrentUser } from '@/lib/session';
export const dynamic = 'force-dynamic';
const CSV_COLUMNS = [
{ key: 'setNumber', label: 'Setnummer' },
{ key: 'setName', label: 'Name' },
{ key: 'manufacturer', label: 'Hersteller' },
{ key: 'theme', label: 'Thema' },
{ key: 'year', label: 'Jahr' },
{ key: 'pieceCount', label: 'Teile' },
{ key: 'status', label: 'Status' },
{ key: 'completeness', label: 'Vollständigkeit' },
{ key: 'condition', label: 'Zustand' },
{ key: 'location', label: 'Standort' },
{ key: 'acquiredAt', label: 'Im Besitz seit' },
{ key: 'acquiredPrice', label: 'Kaufpreis (EUR)' },
{ key: 'retailPrice', label: 'UVP (EUR)' },
{ key: 'acquiredFrom', label: 'Gekauft bei' },
{ key: 'giftedBy', label: 'Geschenkt von' },
{ key: 'notes', label: 'Notizen' },
];
/**
* Downloads.
*
* The full archive is for admins - it contains the whole collection. The CSV
* of the collection is available to every signed-in member.
*/
export async function GET(
_request: Request,
context: { params: Promise<{ kind: string }> },
) {
const user = await getCurrentUser();
if (!user) return new NextResponse('Nicht angemeldet', { status: 401 });
const { kind } = await context.params;
if (kind === 'csv') {
const rows = listCollectionItems({}).map((item) => ({
setNumber: item.setNumber,
setName: item.setName,
manufacturer: item.manufacturerName,
theme: item.themeName,
year: item.year,
pieceCount: item.pieceCount,
status: statusLabel(item.status),
completeness: completenessLabel(item.completeness),
condition: conditionLabel(item.condition),
location: item.locationName,
acquiredAt: item.acquiredAt ? formatDate(item.acquiredAt) : '',
acquiredPrice: centsToInput(item.acquiredPriceCents),
retailPrice: centsToInput(item.retailPriceCents),
acquiredFrom: item.acquiredFrom,
giftedBy: item.giftedBy,
notes: item.notes,
}));
return new NextResponse(buildCollectionCsv(rows, CSV_COLUMNS), {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${exportFileName('csv')}"`,
'Cache-Control': 'no-store',
},
});
}
if (user.role !== 'admin') {
return new NextResponse('Nur Administratoren dürfen die Daten exportieren', { status: 403 });
}
if (kind === 'json') {
return new NextResponse(JSON.stringify(buildExportPayload(), null, 2), {
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${exportFileName('json')}"`,
'Cache-Control': 'no-store',
},
});
}
if (kind === 'zip') {
const archive = buildExportZip();
return new NextResponse(archive as unknown as BodyInit, {
headers: {
'Content-Type': 'application/zip',
'Content-Length': String(archive.byteLength),
'Content-Disposition': `attachment; filename="${exportFileName('zip')}"`,
'Cache-Control': 'no-store',
},
});
}
return new NextResponse('Unbekanntes Format', { status: 404 });
}
+138
View File
@@ -0,0 +1,138 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { AlertTriangle, Download, FileSpreadsheet, FileText, Loader2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { importArchiveAction } from '@/app/(app)/verwaltung/daten/actions';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
const CONFIRMATION = 'ERSETZEN';
export function DataTransfer() {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [confirmation, setConfirmation] = useState('');
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
startTransition(async () => {
const result = await importArchiveAction(new FormData(form));
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success(
`Eingelesen: ${result.data.sets} Sets, ${result.data.collectionItems} Sammlungseinträge, ${result.data.wishlists} Wunschlisten, ${result.data.files} Dateien.`,
);
form.reset();
setConfirmation('');
router.refresh();
});
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Exportieren</CardTitle>
<CardDescription>
Zum Umziehen, zum Nachsehen außerhalb der App oder als zweites Netz. Das eigentliche
Backup bleibt das Verzeichnis <code className="font-mono text-xs">./data</code>.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button asChild size="lg">
<a href="/api/export/zip" download>
<Download />
Alles als ZIP
</a>
</Button>
<Button asChild variant="outline" size="lg">
<a href="/api/export/json" download>
<FileText />
Nur Daten (JSON)
</a>
</Button>
<Button asChild variant="outline" size="lg">
<a href="/api/export/csv" download>
<FileSpreadsheet />
Sammlung als CSV
</a>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Was im Export steckt</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>
<span className="font-medium text-foreground">Dabei:</span> Hersteller, Themen, Sets,
Anleitungen, Standorte, Sammlungseinträge, Fehlteile, Fotos und Wunschlisten – im ZIP
zusätzlich alle hochgeladenen Dateien.
</p>
<p>
<span className="font-medium text-foreground">Nicht dabei:</span> Konten, Passwörter
und Passkeys. Ein Export voller Passwort-Hashes im Download-Ordner wäre ein Risiko,
kein Backup. Ebenfalls draußen: Reservierungen und Teilen-Links – die gehören zu der
Instanz, auf der sie entstanden sind.
</p>
</CardContent>
</Card>
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-destructive" aria-hidden />
Importieren
</CardTitle>
<CardDescription>
Ein Import <strong>ersetzt</strong> alle vorhandenen Daten – Katalog, Sammlung und
Wunschlisten. Zusammenführen ist nicht möglich. Wunschlisten gehören danach dir und
sind zunächst nicht geteilt.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<Field label="Archiv" htmlFor="archive" required>
<Input id="archive" name="archive" type="file" accept=".zip,application/zip" required />
</Field>
<Field
label={`Zum Bestätigen ${CONFIRMATION} eintippen`}
htmlFor="confirmation"
required
>
<Input
id="confirmation"
name="confirmation"
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
placeholder={CONFIRMATION}
autoComplete="off"
/>
</Field>
<Button
type="submit"
size="lg"
variant="destructive"
disabled={pending || confirmation !== CONFIRMATION}
>
{pending ? <Loader2 className="animate-spin" /> : <Upload />}
Daten ersetzen
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -9,6 +9,7 @@ const tabs = [
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
{ href: '/verwaltung/daten', label: 'Daten', adminOnly: true },
] as const;
export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) {
+304
View File
@@ -0,0 +1,304 @@
import 'server-only';
import fs from 'node:fs';
import path from 'node:path';
import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
import { db } from '@/db';
import * as schema from '@/db/schema';
import { env } from '@/env';
import { formatDate } from './format';
/**
* Export and import of the whole collection.
*
* What this is: a portable copy of the data - to move to another machine, to
* look at outside the app, or as a second safety net.
*
* What this is not: a replacement for backing up ./data. Accounts, passwords
* and passkeys are deliberately left out, because a JSON file full of password
* hashes in someone's download folder is a liability, not a backup.
*/
export const EXPORT_FORMAT_VERSION = 1;
export type ExportPayload = {
formatVersion: number;
exportedAt: string;
app: string;
data: {
manufacturers: unknown[];
themes: unknown[];
sets: unknown[];
tags: unknown[];
setTags: unknown[];
instructions: unknown[];
locations: unknown[];
collectionItems: unknown[];
missingParts: unknown[];
photos: unknown[];
wishlists: unknown[];
wishlistItems: unknown[];
};
};
/** Everything except accounts and sessions. */
export function buildExportPayload(): ExportPayload {
return {
formatVersion: EXPORT_FORMAT_VERSION,
exportedAt: new Date().toISOString(),
app: 'bricktrack',
data: {
manufacturers: db.select().from(schema.manufacturer).all(),
themes: db.select().from(schema.theme).all(),
sets: db.select().from(schema.set).all(),
tags: db.select().from(schema.tag).all(),
setTags: db.select().from(schema.setTag).all(),
instructions: db.select().from(schema.instruction).all(),
locations: db.select().from(schema.location).all(),
collectionItems: db.select().from(schema.collectionItem).all(),
missingParts: db.select().from(schema.missingPart).all(),
photos: db.select().from(schema.photo).all(),
// Reservations are personal data of people without an account and have
// no meaning outside the running instance - so they stay behind.
wishlists: db
.select()
.from(schema.wishlist)
.all()
.map(({ shareToken, ...rest }) => ({ ...rest, shareToken: null })),
wishlistItems: db
.select()
.from(schema.wishlistItem)
.all()
.map(({ reservationTokenHash, reservedByName, reservedAt, ...rest }) => rest),
},
};
}
/** Collects every uploaded file, keyed by its path relative to FILES_DIR. */
function collectFiles(): Record<string, Uint8Array> {
const files: Record<string, Uint8Array> = {};
const root = env.FILES_DIR;
if (!fs.existsSync(root)) return files;
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const absolute = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(absolute);
continue;
}
const relative = path.relative(root, absolute).split(path.sep).join('/');
files[`files/${relative}`] = new Uint8Array(fs.readFileSync(absolute));
}
};
walk(root);
return files;
}
/** One archive with the data and every uploaded file. */
export function buildExportZip(): Uint8Array {
const payload = buildExportPayload();
return zipSync(
{
'bricktrack.json': strToU8(JSON.stringify(payload, null, 2)),
...collectFiles(),
},
// Level 6 is the sweet spot: JPEGs and PDFs barely compress anyway.
{ level: 6 },
);
}
export function exportFileName(extension: string): string {
const today = formatDate(new Date()).split('.').reverse().join('-');
return `bricktrack-${today}.${extension}`;
}
// ---------------------------------------------------------------------------
// CSV
// ---------------------------------------------------------------------------
function csvCell(value: unknown): string {
if (value === null || value === undefined) return '';
const text = String(value);
return /[";\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
/**
* The collection as CSV, semicolon separated with a BOM - that is what German
* Excel opens without asking questions.
*/
export function buildCollectionCsv(
rows: Array<Record<string, string | number | null>>,
columns: Array<{ key: string; label: string }>,
): string {
const lines = [columns.map((column) => csvCell(column.label)).join(';')];
for (const row of rows) {
lines.push(columns.map((column) => csvCell(row[column.key])).join(';'));
}
return `${lines.join('\r\n')}\r\n`;
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
export class ImportError extends Error {}
export type ImportSummary = {
sets: number;
collectionItems: number;
wishlists: number;
files: number;
};
type ArchiveContents = {
payload: ExportPayload;
files: Record<string, Uint8Array>;
};
function readArchive(bytes: Uint8Array): ArchiveContents {
let entries: Record<string, Uint8Array>;
try {
entries = unzipSync(bytes);
} catch {
throw new ImportError('Die Datei ist kein gültiges Bricktrack-Archiv (ZIP erwartet).');
}
const json = entries['bricktrack.json'];
if (!json) {
throw new ImportError('Im Archiv fehlt bricktrack.json. Stammt es wirklich aus Bricktrack?');
}
let payload: ExportPayload;
try {
payload = JSON.parse(strFromU8(json)) as ExportPayload;
} catch {
throw new ImportError('bricktrack.json lässt sich nicht lesen.');
}
if (payload.app !== 'bricktrack' || typeof payload.data !== 'object') {
throw new ImportError('Das Archiv stammt nicht aus Bricktrack.');
}
if (payload.formatVersion > EXPORT_FORMAT_VERSION) {
throw new ImportError(
`Das Archiv wurde mit einer neueren Version erstellt (Format ${payload.formatVersion}). Bitte zuerst Bricktrack aktualisieren.`,
);
}
const files: Record<string, Uint8Array> = {};
for (const [name, content] of Object.entries(entries)) {
if (name.startsWith('files/') && !name.endsWith('/')) {
files[name.slice('files/'.length)] = content;
}
}
return { payload, files };
}
/** Guards against a path escaping FILES_DIR through the archive. */
function safeTarget(relative: string): string | null {
const absolute = path.resolve(env.FILES_DIR, relative);
const root = path.resolve(env.FILES_DIR);
return absolute.startsWith(root + path.sep) ? absolute : null;
}
/**
* Replaces all application data with the contents of the archive.
*
* Deliberately a replacement and not a merge: merging two collections would
* need conflict rules for every table, and the result would be impossible to
* predict. Wishlists are handed to the importing admin, since the original
* owner's account does not travel with the archive.
*/
export function importArchive(bytes: Uint8Array, ownerUserId: string): ImportSummary {
const { payload, files } = readArchive(bytes);
const data = payload.data;
db.transaction((tx) => {
// Order matters only for readability - the foreign keys cascade anyway.
tx.delete(schema.wishlistItem).run();
tx.delete(schema.wishlist).run();
tx.delete(schema.missingPart).run();
tx.delete(schema.photo).run();
tx.delete(schema.instruction).run();
tx.delete(schema.collectionItem).run();
tx.delete(schema.setTag).run();
tx.delete(schema.set).run();
tx.delete(schema.tag).run();
tx.delete(schema.theme).run();
tx.delete(schema.manufacturer).run();
tx.delete(schema.location).run();
const insertAll = (table: never, rows: unknown[]) => {
if (!Array.isArray(rows) || rows.length === 0) return;
// Insert in batches: SQLite caps the number of bound variables.
for (let index = 0; index < rows.length; index += 100) {
tx.insert(table)
.values(rows.slice(index, index + 100) as never)
.run();
}
};
insertAll(schema.manufacturer as never, revive(data.manufacturers));
insertAll(schema.location as never, revive(data.locations));
insertAll(schema.theme as never, revive(data.themes));
insertAll(schema.set as never, revive(data.sets));
insertAll(schema.tag as never, revive(data.tags));
insertAll(schema.setTag as never, revive(data.setTags));
insertAll(schema.instruction as never, revive(data.instructions));
insertAll(schema.collectionItem as never, revive(data.collectionItems));
insertAll(schema.missingPart as never, revive(data.missingParts));
insertAll(schema.photo as never, revive(data.photos));
const wishlists = revive(data.wishlists).map((row) => ({
...(row as Record<string, unknown>),
ownerUserId,
// Fresh tokens: links from the exporting instance must not resurrect.
shareToken: crypto.randomUUID().replace(/-/g, ''),
isPublic: false,
}));
insertAll(schema.wishlist as never, wishlists);
insertAll(schema.wishlistItem as never, revive(data.wishlistItems));
});
let restored = 0;
for (const [relative, content] of Object.entries(files)) {
const target = safeTarget(relative);
if (!target) continue;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
restored += 1;
}
return {
sets: data.sets?.length ?? 0,
collectionItems: data.collectionItems?.length ?? 0,
wishlists: data.wishlists?.length ?? 0,
files: restored,
};
}
/**
* JSON has no date type, so timestamps come back as strings. Drizzle expects
* Date objects for its timestamp columns.
*/
function revive(rows: unknown[]): unknown[] {
if (!Array.isArray(rows)) return [];
return rows.map((row) => {
if (typeof row !== 'object' || row === null) return row;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row as Record<string, unknown>)) {
const looksLikeDate =
typeof value === 'string' &&
/(At|Date)$/.test(key) &&
/^\d{4}-\d{2}-\d{2}T/.test(value);
result[key] = looksLikeDate ? new Date(value) : value;
}
return result;
});
}
+313
View File
@@ -0,0 +1,313 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
// A throwaway database for this file alone, so the round trip can delete
// everything without touching the development data.
const DATA_DIR = path.resolve(process.cwd(), 'data-test-export');
process.env.DATA_DIR = DATA_DIR;
fs.rmSync(DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(path.join(DATA_DIR, 'db'), { recursive: true });
fs.mkdirSync(path.join(DATA_DIR, 'files', 'images', 'sets'), { recursive: true });
{
const sqlite = new Database(path.join(DATA_DIR, 'db', 'bricktrack.db'));
migrate(drizzle(sqlite), { migrationsFolder: path.resolve(process.cwd(), 'drizzle') });
sqlite.close();
}
const { db } = await import('@/db');
const schema = await import('@/db/schema');
const { buildExportPayload, buildExportZip, importArchive, ImportError, buildCollectionCsv } =
await import('@/lib/export');
const now = new Date('2026-09-07T10:00:00.000Z');
const USER_ID = 'user-1';
const OTHER_USER_ID = 'user-2';
function seed() {
db.insert(schema.user)
.values([
{
id: USER_ID,
name: 'Florian',
email: 'florian@example.test',
emailVerified: false,
role: 'admin',
createdAt: now,
updatedAt: now,
},
{
id: OTHER_USER_ID,
name: 'Mama',
email: 'mama@example.test',
emailVerified: false,
role: 'member',
createdAt: now,
updatedAt: now,
},
])
.run();
db.insert(schema.manufacturer)
.values({ id: 'm-1', name: 'Lego', slug: 'lego', createdAt: now, updatedAt: now })
.run();
db.insert(schema.theme)
.values({ id: 't-1', manufacturerId: 'm-1', name: 'City', createdAt: now, updatedAt: now })
.run();
db.insert(schema.set)
.values({
id: 's-1',
manufacturerId: 'm-1',
themeId: 't-1',
setNumber: '60398',
name: 'Familienhaus und Elektroauto',
year: 2023,
pieceCount: 462,
retailPriceCents: 4999,
imagePath: 'images/sets/s-1/bild.webp',
createdAt: now,
updatedAt: now,
})
.run();
db.insert(schema.collectionItem)
.values({
id: 'c-1',
setId: 's-1',
status: 'built',
completeness: 'missing_parts',
acquiredPriceCents: 4500,
createdAt: now,
updatedAt: now,
})
.run();
db.insert(schema.missingPart)
.values({
id: 'p-1',
collectionItemId: 'c-1',
partNumber: '3001',
colorName: 'Rot',
quantity: 2,
resolved: false,
createdAt: now,
})
.run();
db.insert(schema.wishlist)
.values({
id: 'w-1',
ownerUserId: OTHER_USER_ID,
title: 'Weihnachten 2026',
occasion: 'christmas',
shareToken: 'geheimer-token-der-nicht-wandern-darf',
isPublic: true,
surpriseMode: false,
createdAt: now,
updatedAt: now,
})
.run();
db.insert(schema.wishlistItem)
.values({
id: 'wi-1',
wishlistId: 'w-1',
setId: 's-1',
priority: 1,
sortOrder: 0,
reservedByName: 'Tante Anne',
reservedAt: now,
reservationTokenHash: 'a'.repeat(64),
createdAt: now,
})
.run();
fs.mkdirSync(path.join(DATA_DIR, 'files', 'images', 'sets', 's-1'), { recursive: true });
fs.writeFileSync(
path.join(DATA_DIR, 'files', 'images', 'sets', 's-1', 'bild.webp'),
Buffer.from([1, 2, 3, 4, 5]),
);
}
function clearAll() {
db.delete(schema.wishlistItem).run();
db.delete(schema.wishlist).run();
db.delete(schema.missingPart).run();
db.delete(schema.collectionItem).run();
db.delete(schema.set).run();
db.delete(schema.theme).run();
db.delete(schema.manufacturer).run();
db.delete(schema.user).run();
}
beforeAll(() => {
clearAll();
});
beforeEach(() => {
clearAll();
seed();
});
describe('Export', () => {
it('nimmt Katalog, Sammlung und Wunschlisten mit', () => {
const payload = buildExportPayload();
expect(payload.app).toBe('bricktrack');
expect(payload.data.manufacturers).toHaveLength(1);
expect(payload.data.sets).toHaveLength(1);
expect(payload.data.collectionItems).toHaveLength(1);
expect(payload.data.missingParts).toHaveLength(1);
expect(payload.data.wishlists).toHaveLength(1);
expect(payload.data.wishlistItems).toHaveLength(1);
});
it('nimmt weder Teilen-Token noch Reservierungen mit', () => {
const payload = buildExportPayload();
const serialized = JSON.stringify(payload);
expect(serialized).not.toContain('geheimer-token-der-nicht-wandern-darf');
expect(serialized).not.toContain('Tante Anne');
expect(serialized).not.toContain('a'.repeat(64));
});
it('nimmt keine Konten mit', () => {
const serialized = JSON.stringify(buildExportPayload());
expect(serialized).not.toContain('florian@example.test');
expect(serialized).not.toContain('mama@example.test');
});
it('packt die hochgeladenen Dateien ins Archiv', () => {
const archive = buildExportZip();
expect(archive.byteLength).toBeGreaterThan(0);
// Der Dateiname steht im ZIP-Verzeichnis im Klartext.
expect(Buffer.from(archive).toString('latin1')).toContain('files/images/sets/s-1/bild.webp');
});
});
describe('Import', () => {
it('stellt einen vollstaendigen Rundweg wieder her', () => {
const archive = buildExportZip();
clearAll();
// Der importierende Admin muss existieren - die Konten reisen nicht mit.
db.insert(schema.user)
.values({
id: USER_ID,
name: 'Florian',
email: 'florian@example.test',
emailVerified: false,
role: 'admin',
createdAt: now,
updatedAt: now,
})
.run();
const summary = importArchive(archive, USER_ID);
expect(summary.sets).toBe(1);
expect(summary.collectionItems).toBe(1);
expect(summary.wishlists).toBe(1);
expect(summary.files).toBeGreaterThan(0);
const sets = db.select().from(schema.set).all();
expect(sets).toHaveLength(1);
expect(sets[0]).toMatchObject({
setNumber: '60398',
name: 'Familienhaus und Elektroauto',
pieceCount: 462,
retailPriceCents: 4999,
});
// Zeitstempel muessen als Date zurueckkommen, nicht als String.
expect(sets[0]!.createdAt).toBeInstanceOf(Date);
expect(db.select().from(schema.missingPart).all()).toHaveLength(1);
});
it('uebergibt die Wunschlisten dem importierenden Admin und teilt sie nicht', () => {
const archive = buildExportZip();
clearAll();
db.insert(schema.user)
.values({
id: USER_ID,
name: 'Florian',
email: 'florian@example.test',
emailVerified: false,
role: 'admin',
createdAt: now,
updatedAt: now,
})
.run();
importArchive(archive, USER_ID);
const [list] = db.select().from(schema.wishlist).all();
expect(list?.ownerUserId).toBe(USER_ID);
expect(list?.isPublic).toBe(false);
expect(list?.shareToken).not.toBe('geheimer-token-der-nicht-wandern-darf');
expect(list?.shareToken.length).toBeGreaterThan(16);
});
it('ersetzt vorhandene Daten, statt sie zu vermischen', () => {
const archive = buildExportZip();
// Zweites Set, das im Archiv nicht vorkommt.
db.insert(schema.set)
.values({
id: 's-2',
manufacturerId: 'm-1',
setNumber: '99999',
name: 'Wird ersetzt',
createdAt: now,
updatedAt: now,
})
.run();
importArchive(archive, USER_ID);
const numbers = db
.select({ setNumber: schema.set.setNumber })
.from(schema.set)
.all()
.map((row) => row.setNumber);
expect(numbers).toEqual(['60398']);
});
it('lehnt ein fremdes Archiv ab, ohne etwas zu loeschen', () => {
const before = db.select().from(schema.set).all().length;
expect(() => importArchive(new Uint8Array([1, 2, 3]), USER_ID)).toThrow(ImportError);
expect(db.select().from(schema.set).all()).toHaveLength(before);
});
});
describe('CSV', () => {
it('trennt mit Semikolon und beginnt mit einer BOM', () => {
const csv = buildCollectionCsv([{ a: 'x', b: 1 }], [
{ key: 'a', label: 'Spalte A' },
{ key: 'b', label: 'Spalte B' },
]);
expect(csv.startsWith('')).toBe(true);
expect(csv).toContain('Spalte A;Spalte B');
expect(csv).toContain('x;1');
});
it('maskiert Semikolon, Anfuehrungszeichen und Zeilenumbrueche', () => {
const csv = buildCollectionCsv(
[{ note: 'a;b "c"\nd' }],
[{ key: 'note', label: 'Notiz' }],
);
expect(csv).toContain('"a;b ""c""\nd"');
});
});