diff --git a/.gitignore b/.gitignore index 23c007c..7e8447c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ next-env.d.ts /data-shots /data-docker /backups +/data-test +/data-test-export # production *.tsbuildinfo diff --git a/package.json b/package.json index e24d09e..903181a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7386ebd..8fef224 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/(app)/verwaltung/daten/actions.ts b/src/app/(app)/verwaltung/daten/actions.ts new file mode 100644 index 0000000..7699fb5 --- /dev/null +++ b/src/app/(app)/verwaltung/daten/actions.ts @@ -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> { + 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.', + ); + } +} diff --git a/src/app/(app)/verwaltung/daten/page.tsx b/src/app/(app)/verwaltung/daten/page.tsx new file mode 100644 index 0000000..ace9e61 --- /dev/null +++ b/src/app/(app)/verwaltung/daten/page.tsx @@ -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 ; +} diff --git a/src/app/api/export/[kind]/route.ts b/src/app/api/export/[kind]/route.ts new file mode 100644 index 0000000..8207855 --- /dev/null +++ b/src/app/api/export/[kind]/route.ts @@ -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 }); +} diff --git a/src/components/verwaltung/data-transfer.tsx b/src/components/verwaltung/data-transfer.tsx new file mode 100644 index 0000000..47537d5 --- /dev/null +++ b/src/components/verwaltung/data-transfer.tsx @@ -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) { + 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 ( +
+ + + Exportieren + + Zum Umziehen, zum Nachsehen außerhalb der App oder als zweites Netz. Das eigentliche + Backup bleibt das Verzeichnis ./data. + + + + + + + + + + + + Was im Export steckt + + +

+ Dabei: Hersteller, Themen, Sets, + Anleitungen, Standorte, Sammlungseinträge, Fehlteile, Fotos und Wunschlisten – im ZIP + zusätzlich alle hochgeladenen Dateien. +

+

+ Nicht dabei: 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. +

+
+
+ + + + + + Importieren + + + Ein Import ersetzt alle vorhandenen Daten – Katalog, Sammlung und + Wunschlisten. Zusammenführen ist nicht möglich. Wunschlisten gehören danach dir und + sind zunächst nicht geteilt. + + + +
+ + + + + + setConfirmation(event.target.value)} + placeholder={CONFIRMATION} + autoComplete="off" + /> + + + +
+
+
+
+ ); +} diff --git a/src/components/verwaltung/verwaltung-nav.tsx b/src/components/verwaltung/verwaltung-nav.tsx index 017ac23..ecc9a6d 100644 --- a/src/components/verwaltung/verwaltung-nav.tsx +++ b/src/components/verwaltung/verwaltung-nav.tsx @@ -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 }) { diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 0000000..5ae0265 --- /dev/null +++ b/src/lib/export.ts @@ -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 { + const files: Record = {}; + 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>, + 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; +}; + +function readArchive(bytes: Uint8Array): ArchiveContents { + let entries: Record; + 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 = {}; + 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), + 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 = {}; + for (const [key, value] of Object.entries(row as Record)) { + 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; + }); +} diff --git a/tests/unit/export.test.ts b/tests/unit/export.test.ts new file mode 100644 index 0000000..fd4df7c --- /dev/null +++ b/tests/unit/export.test.ts @@ -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"'); + }); +});