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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user