test: unit-tests der kernlogik und playwright-smoke-test

50 Unit-Tests mit Vitest fuer die Stellen, an denen ein Fehler teuer
waere: Besitzstatus, Mehrfachauswahl inklusive Ueberspringen von
Dubletten und bereits besessenen Sets, Token-Erzeugung und -Vergleich,
Cent-Umrechnung und Slugs.

Der Playwright-Smoke-Test laeuft den ganzen Weg: Registrierung des
ersten Nutzers, Hersteller anlegen, Set anlegen, auf eine neue
Wunschliste setzen, Link holen und als Besucher ohne Konto reservieren.
Er nutzt ein eigenes Datenverzeichnis (./data-e2e), das vor jedem Lauf
geleert wird.

Dabei aufgefallen und behoben: eine spontan aus dem Katalog angelegte
Wunschliste war nicht teilbar, weil das Feld isPublic ohne Formular
auf false fiel. Ausserdem ist das Sternchen an Pflichtfeldern jetzt
aria-hidden, damit der zugaengliche Name des Feldes sauber bleibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-06 23:31:21 +02:00
co-authored by Claude Opus 5
parent 79b0133083
commit b0152b8667
16 changed files with 1289 additions and 746 deletions
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { centsToInput, formatEuro, parseEuroInput, sumCents } from '@/lib/money';
describe('Preiseingabe nach Cent', () => {
it('versteht das deutsche Komma', () => {
expect(parseEuroInput('12,99')).toBe(1299);
expect(parseEuroInput('0,05')).toBe(5);
});
it('versteht den englischen Punkt', () => {
expect(parseEuroInput('12.99')).toBe(1299);
});
it('versteht Tausendertrennzeichen in beiden Schreibweisen', () => {
expect(parseEuroInput('1.234,56')).toBe(123456);
expect(parseEuroInput('1,234.56')).toBe(123456);
});
it('ignoriert Euro-Zeichen und Leerzeichen', () => {
expect(parseEuroInput(' 89,99 € ')).toBe(8999);
});
it('liefert null fuer leere Eingaben', () => {
expect(parseEuroInput('')).toBeNull();
expect(parseEuroInput(' ')).toBeNull();
expect(parseEuroInput(null)).toBeNull();
expect(parseEuroInput(undefined)).toBeNull();
});
it('liefert null fuer Unsinn und negative Betraege', () => {
expect(parseEuroInput('abc')).toBeNull();
expect(parseEuroInput('-5')).toBeNull();
});
it('rundet auf ganze Cent', () => {
expect(parseEuroInput('9,999')).toBe(1000);
expect(parseEuroInput('9,994')).toBe(999);
});
});
describe('Preisanzeige', () => {
it('formatiert Cent als Euro-Betrag', () => {
// Intl setzt ein schmales geschuetztes Leerzeichen vor das Zeichen.
expect(formatEuro(1299).replace(/ | /g, ' ')).toBe('12,99 €');
});
it('zeigt fuer unbekannte Preise einen Gedankenstrich', () => {
expect(formatEuro(null)).toBe('–');
expect(formatEuro(undefined)).toBe('–');
});
it('macht aus Cent wieder eine Eingabe ohne Waehrungszeichen', () => {
expect(centsToInput(1299)).toBe('12,99');
expect(centsToInput(null)).toBe('');
});
it('ueberlebt den Rundweg Eingabe -> Cent -> Eingabe', () => {
for (const input of ['0,01', '12,99', '449,99', '1.234,56']) {
const cents = parseEuroInput(input);
expect(cents).not.toBeNull();
expect(parseEuroInput(centsToInput(cents))).toBe(cents);
}
});
});
describe('Summen', () => {
it('addiert exakt, ohne Float-Fehler', () => {
// 0,10 + 0,20 waere als Float 0.30000000000000004.
expect(sumCents([10, 20])).toBe(30);
});
it('ignoriert unbekannte Preise', () => {
expect(sumCents([1299, null, undefined, 100])).toBe(1399);
expect(sumCents([])).toBe(0);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import type { CollectionStatus } from '@/db/types';
import {
countOwnedCopies,
isOwningStatus,
isSetOwned,
OWNED_STATUSES,
parseOwnershipFilter,
} from '@/lib/ownership';
describe('Besitzstatus', () => {
it('zaehlt jeden Status ausser sold als Besitz', () => {
for (const status of OWNED_STATUSES) {
expect(isOwningStatus(status)).toBe(true);
}
expect(isOwningStatus('sold')).toBe(false);
});
it('gilt ohne Sammlungseintrag als nicht vorhanden', () => {
expect(isSetOwned([])).toBe(false);
});
it('gilt als vorhanden, sobald ein Exemplar nicht verkauft ist', () => {
expect(isSetOwned(['owned'])).toBe(true);
expect(isSetOwned(['built'])).toBe(true);
expect(isSetOwned(['disassembled'])).toBe(true);
expect(isSetOwned(['lent_out'])).toBe(true);
});
it('gilt als fehlend, wenn alle Exemplare verkauft sind', () => {
expect(isSetOwned(['sold'])).toBe(false);
expect(isSetOwned(['sold', 'sold'])).toBe(false);
});
it('gilt als vorhanden, wenn ein Exemplar verkauft und eines geblieben ist', () => {
const statuses: CollectionStatus[] = ['sold', 'built'];
expect(isSetOwned(statuses)).toBe(true);
});
it('zaehlt nur die nicht verkauften Exemplare', () => {
expect(countOwnedCopies(['owned', 'owned', 'sold'])).toBe(2);
expect(countOwnedCopies(['sold'])).toBe(0);
expect(countOwnedCopies([])).toBe(0);
});
});
describe('Besitz-Filter aus der URL', () => {
it('erkennt die vier gueltigen Werte', () => {
expect(parseOwnershipFilter('alle')).toBe('alle');
expect(parseOwnershipFilter('sammlung')).toBe('sammlung');
expect(parseOwnershipFilter('fehlt')).toBe('fehlt');
expect(parseOwnershipFilter('wunschliste')).toBe('wunschliste');
});
it('faellt bei Unsinn auf "alle" zurueck', () => {
expect(parseOwnershipFilter('quatsch')).toBe('alle');
expect(parseOwnershipFilter(undefined)).toBe('alle');
expect(parseOwnershipFilter(null)).toBe('alle');
expect(parseOwnershipFilter('')).toBe('alle');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { slugify, uniqueSlug } from '@/lib/slug';
describe('Slugs', () => {
it('schreibt deutsche Umlaute aus', () => {
expect(slugify('Grüne Kiste')).toBe('gruene-kiste');
expect(slugify('Öl')).toBe('oel');
expect(slugify('Straße')).toBe('strasse');
});
it('entfernt uebrige Akzente', () => {
expect(slugify('Café Déjà vu')).toBe('cafe-deja-vu');
});
it('macht aus Sonderzeichen einzelne Trennstriche', () => {
expect(slugify('Lego City --- Feuerwehr!')).toBe('lego-city-feuerwehr');
});
it('laesst keine Trennstriche am Rand stehen', () => {
expect(slugify(' -Playmobil- ')).toBe('playmobil');
});
it('kuerzt sehr lange Namen', () => {
expect(slugify('a'.repeat(200))).toHaveLength(60);
});
});
describe('Eindeutige Slugs', () => {
it('nimmt den Slug direkt, wenn er frei ist', () => {
expect(uniqueSlug('Lego', [])).toBe('lego');
});
it('haengt einen Zaehler an, bis der Slug frei ist', () => {
expect(uniqueSlug('Lego', ['lego'])).toBe('lego-2');
expect(uniqueSlug('Lego', ['lego', 'lego-2'])).toBe('lego-3');
});
it('faellt bei Namen ohne verwertbare Zeichen auf "eintrag" zurueck', () => {
expect(uniqueSlug('###', [])).toBe('eintrag');
expect(uniqueSlug('###', ['eintrag'])).toBe('eintrag-2');
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { createToken, hashToken, tokensMatch, TOKEN_BYTES } from '@/lib/tokens';
describe('Token-Erzeugung', () => {
it('erzeugt 32 Byte Zufall als base64url', () => {
const token = createToken();
expect(TOKEN_BYTES).toBe(32);
expect(Buffer.from(token, 'base64url')).toHaveLength(32);
// base64url darf nicht in URLs escaped werden muessen.
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('erzeugt bei jedem Aufruf einen anderen Token', () => {
const tokens = new Set(Array.from({ length: 500 }, () => createToken()));
expect(tokens.size).toBe(500);
});
});
describe('Token-Hashing', () => {
it('liefert einen stabilen SHA-256-Hex-Hash', () => {
const hash = hashToken('beispiel');
expect(hash).toHaveLength(64);
expect(hash).toMatch(/^[0-9a-f]+$/);
expect(hashToken('beispiel')).toBe(hash);
});
it('speichert den Klartext nirgends im Hash', () => {
expect(hashToken('geheim')).not.toContain('geheim');
});
it('erkennt den passenden Token', () => {
const token = createToken();
expect(tokensMatch(token, hashToken(token))).toBe(true);
});
it('weist einen falschen Token ab', () => {
expect(tokensMatch(createToken(), hashToken(createToken()))).toBe(false);
});
it('weist einen fehlenden Hash ab, statt zu vertrauen', () => {
const token = createToken();
expect(tokensMatch(token, null)).toBe(false);
expect(tokensMatch(token, undefined)).toBe(false);
expect(tokensMatch(token, '')).toBe(false);
});
it('weist einen Hash falscher Laenge ab, ohne zu werfen', () => {
expect(tokensMatch(createToken(), 'abc')).toBe(false);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { describeBulkAdd, planBulkAdd, priorityLabel } from '@/lib/wishlist-logic';
describe('Mehrfachauswahl auf die Wunschliste', () => {
it('uebernimmt alles, was weder drauf noch in der Sammlung ist', () => {
const plan = planBulkAdd(['a', 'b', 'c'], new Set(), new Set());
expect(plan.toAdd).toEqual(['a', 'b', 'c']);
expect(plan.duplicates).toEqual([]);
expect(plan.owned).toEqual([]);
});
it('ueberspringt Sets, die schon auf der Liste stehen', () => {
const plan = planBulkAdd(['a', 'b', 'c'], new Set(['b']), new Set());
expect(plan.toAdd).toEqual(['a', 'c']);
expect(plan.duplicates).toEqual(['b']);
});
it('ueberspringt Sets, die schon in der Sammlung sind', () => {
const plan = planBulkAdd(['a', 'b'], new Set(), new Set(['a']));
expect(plan.toAdd).toEqual(['b']);
expect(plan.owned).toEqual(['a']);
});
it('zaehlt ein Set, das beides ist, nur als Dublette', () => {
const plan = planBulkAdd(['a'], new Set(['a']), new Set(['a']));
expect(plan.toAdd).toEqual([]);
expect(plan.duplicates).toEqual(['a']);
expect(plan.owned).toEqual([]);
});
it('entfernt Mehrfachnennungen aus der Auswahl', () => {
const plan = planBulkAdd(['a', 'a', 'b', 'a'], new Set(), new Set());
expect(plan.toAdd).toEqual(['a', 'b']);
});
it('behaelt die Reihenfolge der Auswahl bei', () => {
const plan = planBulkAdd(['c', 'a', 'b'], new Set(), new Set());
expect(plan.toAdd).toEqual(['c', 'a', 'b']);
});
it('kommt mit einer leeren Auswahl klar', () => {
const plan = planBulkAdd([], new Set(['a']), new Set(['b']));
expect(plan).toEqual({ toAdd: [], duplicates: [], owned: [] });
});
});
describe('Rueckmeldung nach der Mehrfachauswahl', () => {
const names = new Map([
['a', '60398 Familienhaus'],
['b', '71427 Lloyds Drache'],
['c', '42172 McLaren P1'],
['d', '70392 Novelmore'],
['e', '2-30500 Mustang'],
]);
it('nennt die Zahl der hinzugefuegten Sets im Singular', () => {
const message = describeBulkAdd({ toAdd: ['a'], duplicates: [], owned: [] }, names);
expect(message).toBe('1 Set hinzugefügt');
});
it('benennt uebersprungene Dubletten mit Namen', () => {
const message = describeBulkAdd({ toAdd: ['a'], duplicates: ['b'], owned: [] }, names);
expect(message).toContain('1 übersprungen (schon auf der Liste)');
expect(message).toContain('71427 Lloyds Drache');
});
it('nennt bei vielen Dubletten nur die ersten drei', () => {
const message = describeBulkAdd(
{ toAdd: [], duplicates: ['a', 'b', 'c', 'd', 'e'], owned: [] },
names,
);
expect(message).toContain('und 2 weitere');
expect(message).not.toContain('70392 Novelmore');
});
it('trennt Dubletten von bereits besessenen Sets', () => {
const message = describeBulkAdd({ toAdd: [], duplicates: ['a'], owned: ['b'] }, names);
expect(message).toContain('schon auf der Liste');
expect(message).toContain('schon in der Sammlung');
});
});
describe('Prioritaeten', () => {
it('hat fuer jede Stufe ein deutsches Label', () => {
expect(priorityLabel(1)).toBe('Großer Wunsch');
expect(priorityLabel(2)).toBe('Gerne');
expect(priorityLabel(3)).toBe('Irgendwann');
});
it('faellt bei unbekannten Werten auf die mittlere Stufe zurueck', () => {
expect(priorityLabel(99)).toBe('Gerne');
});
});