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:
@@ -19,6 +19,7 @@ next-env.d.ts
|
|||||||
|
|
||||||
# runtime data (database, uploads) - never in git, this is the backup target
|
# runtime data (database, uploads) - never in git, this is the backup target
|
||||||
/data
|
/data
|
||||||
|
/data-e2e
|
||||||
|
|
||||||
# production
|
# production
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates the smoke-test database. The directory itself is wiped earlier by
|
||||||
|
* scripts/reset-e2e.mjs, because Playwright starts its web server before this
|
||||||
|
* hook runs and the open database file would block the delete on Windows.
|
||||||
|
*/
|
||||||
|
export default function globalSetup() {
|
||||||
|
execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, DATA_DIR: './data-e2e' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one path that has to work: register, put a set into the catalog, and
|
||||||
|
* share it on a wishlist that a visitor can reserve from without an account.
|
||||||
|
*/
|
||||||
|
test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ page, browser }) => {
|
||||||
|
// --- first run: the very first visitor registers and becomes admin --------
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveURL(/\/registrieren/);
|
||||||
|
// Card titles are plain elements, not headings - hence getByText here.
|
||||||
|
await expect(page.getByText('Erstes Konto anlegen')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel('Name').fill('Florian');
|
||||||
|
await page.getByLabel('E-Mail').fill('test@bricktrack.local');
|
||||||
|
await page.getByLabel('Passwort').fill('smoketest-passwort');
|
||||||
|
await page.getByRole('button', { name: 'Konto anlegen' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
|
||||||
|
|
||||||
|
// --- master data: a set needs a manufacturer -----------------------------
|
||||||
|
await page.goto('/verwaltung/hersteller');
|
||||||
|
await page.getByRole('button', { name: 'Hersteller anlegen' }).click();
|
||||||
|
await page.getByLabel('Name').fill('Lego');
|
||||||
|
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||||
|
await expect(page.getByText('Lego')).toBeVisible();
|
||||||
|
|
||||||
|
// --- catalog: create a set ------------------------------------------------
|
||||||
|
await page.goto('/katalog/neu');
|
||||||
|
await page.getByLabel('Setnummer').fill('60398');
|
||||||
|
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Familienhaus und Elektroauto');
|
||||||
|
await page.getByLabel('Jahr').fill('2023');
|
||||||
|
await page.getByLabel('UVP').fill('49,99');
|
||||||
|
await page.getByRole('button', { name: 'Set anlegen' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Fehlt noch')).toBeVisible();
|
||||||
|
|
||||||
|
// --- wishlist: put the set on a fresh list --------------------------------
|
||||||
|
await page.getByRole('button', { name: 'Auf Wunschliste' }).click();
|
||||||
|
await page.getByRole('combobox', { name: 'Wunschliste' }).selectOption('__neu__');
|
||||||
|
await page.getByLabel('Titel der neuen Liste').fill('Weihnachten 2026');
|
||||||
|
await page.getByRole('button', { name: 'Hinzufügen' }).click();
|
||||||
|
|
||||||
|
// Wait for the confirmation, not for the button label that is on screen anyway.
|
||||||
|
await expect(page.getByText('Auf die Wunschliste gesetzt.')).toBeVisible();
|
||||||
|
|
||||||
|
// --- sharing: grab the public link ---------------------------------------
|
||||||
|
await page.goto('/wunschlisten');
|
||||||
|
// Navigate by href: a toast can still be on screen and would swallow the click.
|
||||||
|
const listHref = await page
|
||||||
|
.getByRole('link', { name: /Weihnachten 2026/ })
|
||||||
|
.getAttribute('href');
|
||||||
|
expect(listHref).toMatch(/^\/wunschlisten\//);
|
||||||
|
await page.goto(listHref!);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
|
||||||
|
|
||||||
|
const shareUrl = await page.getByLabel('Link zum Teilen').inputValue();
|
||||||
|
expect(shareUrl).toContain('/w/');
|
||||||
|
|
||||||
|
// --- the visitor: no account, no session ---------------------------------
|
||||||
|
const visitorContext = await browser.newContext();
|
||||||
|
const visitor = await visitorContext.newPage();
|
||||||
|
await visitor.goto(shareUrl);
|
||||||
|
|
||||||
|
await expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
|
||||||
|
await expect(visitor.getByText('Familienhaus und Elektroauto')).toBeVisible();
|
||||||
|
|
||||||
|
await visitor.getByRole('button', { name: 'Das schenke ich' }).click();
|
||||||
|
await visitor.getByLabel('Dein Name').fill('Tante Anne');
|
||||||
|
await visitor.getByRole('button', { name: 'Reservieren' }).click();
|
||||||
|
|
||||||
|
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
|
||||||
|
await visitor.getByRole('button', { name: 'Alles klar' }).click();
|
||||||
|
await expect(visitor.getByText('Das schenkst du')).toBeVisible();
|
||||||
|
|
||||||
|
// --- back on the owner side: the reservation shows up ---------------------
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText('Reserviert von Tante Anne')).toBeVisible();
|
||||||
|
|
||||||
|
await visitorContext.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => {
|
||||||
|
await page.goto('/sammlung');
|
||||||
|
await expect(page).toHaveURL(/\/anmelden|\/registrieren/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ein unbekannter Share-Token liefert 404', async ({ page }) => {
|
||||||
|
const response = await page.goto('/w/dieser-token-existiert-nicht');
|
||||||
|
expect(response?.status()).toBe(404);
|
||||||
|
});
|
||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "node scripts/reset-e2e.mjs && playwright test",
|
||||||
"screenshots": "playwright test --config=playwright.screenshots.config.ts"
|
"screenshots": "playwright test --config=playwright.screenshots.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test setup.
|
||||||
|
*
|
||||||
|
* Runs against its own data directory (./data-e2e) so a test never touches the
|
||||||
|
* real database. The global setup wipes and migrates it before each run.
|
||||||
|
*/
|
||||||
|
const PORT = 3210;
|
||||||
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
globalSetup: './e2e/global-setup.ts',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
// Generous: the dev server compiles each route on first visit.
|
||||||
|
timeout: 240_000,
|
||||||
|
expect: { timeout: 15_000 },
|
||||||
|
reporter: process.env.CI ? 'github' : 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
locale: 'de-DE',
|
||||||
|
timezoneId: 'Europe/Berlin',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'mobile',
|
||||||
|
// The app is built for phones first, so the smoke test uses one.
|
||||||
|
use: { ...devices['Pixel 7'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: `pnpm exec next dev -p ${PORT}`,
|
||||||
|
url: `${BASE_URL}/api/health`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 180_000,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
DATA_DIR: './data-e2e',
|
||||||
|
BASE_URL,
|
||||||
|
BETTER_AUTH_SECRET: 'playwright-smoke-test-secret-0123456789abcdef',
|
||||||
|
MAX_UPLOAD_MB: '25',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Wipes the Playwright data directory before the smoke run.
|
||||||
|
*
|
||||||
|
* Runs before Playwright starts, because Playwright boots its web server
|
||||||
|
* before global setup - on Windows the open SQLite file would then block the
|
||||||
|
* delete.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const dataDir = path.resolve(process.cwd(), 'data-e2e');
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
|
||||||
@@ -97,7 +97,12 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
|
|||||||
let wishlistId = targetId;
|
let wishlistId = targetId;
|
||||||
|
|
||||||
if (wishlistId === NEW_LIST) {
|
if (wishlistId === NEW_LIST) {
|
||||||
const created = await createWishlist({ title: newTitle, occasion: 'other' });
|
// isPublic: a list created on the fly is meant to be shared right away.
|
||||||
|
const created = await createWishlist({
|
||||||
|
title: newTitle,
|
||||||
|
occasion: 'other',
|
||||||
|
isPublic: 'on',
|
||||||
|
});
|
||||||
if (!created.ok) {
|
if (!created.ok) {
|
||||||
toast.error(created.error);
|
toast.error(created.error);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -69,7 +69,12 @@ export function SetActions({
|
|||||||
let wishlistId = targetId;
|
let wishlistId = targetId;
|
||||||
|
|
||||||
if (wishlistId === NEW_LIST) {
|
if (wishlistId === NEW_LIST) {
|
||||||
const created = await createWishlist({ title: newTitle, occasion: 'other' });
|
// isPublic: a list created on the fly is meant to be shared right away.
|
||||||
|
const created = await createWishlist({
|
||||||
|
title: newTitle,
|
||||||
|
occasion: 'other',
|
||||||
|
isPublic: 'on',
|
||||||
|
});
|
||||||
if (!created.ok) {
|
if (!created.ok) {
|
||||||
toast.error(created.error);
|
toast.error(created.error);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ export function Field({
|
|||||||
<div className={cn('space-y-2', className)}>
|
<div className={cn('space-y-2', className)}>
|
||||||
<Label htmlFor={htmlFor}>
|
<Label htmlFor={htmlFor}>
|
||||||
{label}
|
{label}
|
||||||
{required ? <span className="text-destructive"> *</span> : null}
|
{/* aria-hidden keeps the accessible name clean ("Name", not "Name *");
|
||||||
|
the required state is already carried by the input itself. */}
|
||||||
|
{required ? (
|
||||||
|
<span aria-hidden className="text-destructive">
|
||||||
|
{' *'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</Label>
|
</Label>
|
||||||
{children}
|
{children}
|
||||||
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
|
||||||
|
|||||||
@@ -85,7 +85,13 @@ export function ShareCard({
|
|||||||
<div className="space-y-2 rounded-lg border border-border p-3">
|
<div className="space-y-2 rounded-lg border border-border p-3">
|
||||||
<p className="text-sm font-medium">Link zum Teilen</p>
|
<p className="text-sm font-medium">Link zum Teilen</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input readOnly value={url} onFocus={(event) => event.currentTarget.select()} className="font-mono text-xs" />
|
<Input
|
||||||
|
readOnly
|
||||||
|
aria-label="Link zum Teilen"
|
||||||
|
value={url}
|
||||||
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
<Button variant="outline" size="icon" onClick={copy} aria-label="Link kopieren">
|
<Button variant="outline" size="icon" onClick={copy} aria-label="Link kopieren">
|
||||||
{copied ? <Check className="text-emerald-600" /> : <Copy />}
|
{copied ? <Check className="text-emerald-600" /> : <Copy />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(root, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['tests/unit/**/*.test.ts'],
|
||||||
|
globals: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user