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>
314 lines
8.6 KiB
TypeScript
314 lines
8.6 KiB
TypeScript
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"');
|
||
});
|
||
});
|