From b0152b8667a4603dfa711a3ab6c0d2003e157f1b Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 6 Sep 2026 23:31:21 +0200 Subject: [PATCH] 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) --- .gitignore | 1 + e2e/global-setup.ts | 13 + e2e/smoke.spec.ts | 92 +++ package.json | 144 ++--- playwright.config.ts | 50 ++ scripts/reset-e2e.mjs | 13 + src/components/katalog/catalog-grid.tsx | 617 +++++++++++---------- src/components/katalog/set-actions.tsx | 437 +++++++-------- src/components/ui/field.tsx | 66 ++- src/components/wunschlisten/share-card.tsx | 250 +++++---- tests/unit/money.test.ts | 76 +++ tests/unit/ownership.test.ts | 61 ++ tests/unit/slug.test.ts | 42 ++ tests/unit/tokens.test.ts | 52 ++ tests/unit/wishlist-logic.test.ts | 103 ++++ vitest.config.mts | 18 + 16 files changed, 1289 insertions(+), 746 deletions(-) create mode 100644 e2e/global-setup.ts create mode 100644 e2e/smoke.spec.ts create mode 100644 playwright.config.ts create mode 100644 scripts/reset-e2e.mjs create mode 100644 tests/unit/money.test.ts create mode 100644 tests/unit/ownership.test.ts create mode 100644 tests/unit/slug.test.ts create mode 100644 tests/unit/tokens.test.ts create mode 100644 tests/unit/wishlist-logic.test.ts create mode 100644 vitest.config.mts diff --git a/.gitignore b/.gitignore index fc8ae8e..e5e8b98 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ next-env.d.ts # runtime data (database, uploads) - never in git, this is the backup target /data +/data-e2e # production *.tsbuildinfo diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..e0bb8a4 --- /dev/null +++ b/e2e/global-setup.ts @@ -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' }, + }); +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..9a4047f --- /dev/null +++ b/e2e/smoke.spec.ts @@ -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); +}); diff --git a/package.json b/package.json index 4a9c250..5f2b79a 100644 --- a/package.json +++ b/package.json @@ -1,72 +1,72 @@ -{ - "name": "bricktrack", - "version": "0.1.0", - "private": true, - "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", - "packageManager": "pnpm@10.34.5", - "engines": { - "node": ">=22" - }, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "eslint .", - "typecheck": "tsc --noEmit", - "db:generate": "drizzle-kit generate", - "db:migrate": "tsx scripts/migrate.ts", - "db:seed": "tsx scripts/seed.ts", - "db:studio": "drizzle-kit studio", - "test": "vitest run", - "test:watch": "vitest", - "test:e2e": "playwright test", - "screenshots": "playwright test --config=playwright.screenshots.config.ts" - }, - "dependencies": { - "@better-auth/passkey": "^1.7.3", - "better-auth": "^1.7.3", - "better-sqlite3": "^13.0.3", - "class-variance-authority": "^0.7.1", - "cn": "^0.2.6", - "date-fns": "^4.4.0", - "drizzle-orm": "^0.45.2", - "lucide-react": "^1.41.0", - "next": "15.5.25", - "next-themes": "^0.4.6", - "pdfjs-dist": "^6.3.289", - "radix-ui": "^1.6.7", - "react": "19.1.0", - "react-dom": "19.1.0", - "server-only": "^0.0.1", - "sharp": "^0.35.4", - "sonner": "^2.0.8", - "tw-animate-css": "^1.4.0", - "zod": "^4.5.4" - }, - "devDependencies": { - "@eslint/eslintrc": "^3", - "@playwright/test": "^1.63.0", - "@tailwindcss/postcss": "^4", - "@types/better-sqlite3": "^9.6.0", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "dotenv": "^17.4.2", - "drizzle-kit": "^0.31.10", - "eslint": "^9", - "eslint-config-next": "15.5.25", - "shadcn": "^4.21.0", - "tailwindcss": "^4", - "tsx": "^4.23.13", - "typescript": "^5", - "vitest": "^5.0.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "better-sqlite3", - "esbuild", - "sharp", - "unrs-resolver" - ] - } -} +{ + "name": "bricktrack", + "version": "0.1.0", + "private": true, + "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", + "packageManager": "pnpm@10.34.5", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx scripts/migrate.ts", + "db:seed": "tsx scripts/seed.ts", + "db:studio": "drizzle-kit studio", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "node scripts/reset-e2e.mjs && playwright test", + "screenshots": "playwright test --config=playwright.screenshots.config.ts" + }, + "dependencies": { + "@better-auth/passkey": "^1.7.3", + "better-auth": "^1.7.3", + "better-sqlite3": "^13.0.3", + "class-variance-authority": "^0.7.1", + "cn": "^0.2.6", + "date-fns": "^4.4.0", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.41.0", + "next": "15.5.25", + "next-themes": "^0.4.6", + "pdfjs-dist": "^6.3.289", + "radix-ui": "^1.6.7", + "react": "19.1.0", + "react-dom": "19.1.0", + "server-only": "^0.0.1", + "sharp": "^0.35.4", + "sonner": "^2.0.8", + "tw-animate-css": "^1.4.0", + "zod": "^4.5.4" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@playwright/test": "^1.63.0", + "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", + "eslint": "^9", + "eslint-config-next": "15.5.25", + "shadcn": "^4.21.0", + "tailwindcss": "^4", + "tsx": "^4.23.13", + "typescript": "^5", + "vitest": "^5.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3", + "esbuild", + "sharp", + "unrs-resolver" + ] + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..7ab6121 --- /dev/null +++ b/playwright.config.ts @@ -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', + }, + }, +}); diff --git a/scripts/reset-e2e.mjs b/scripts/reset-e2e.mjs new file mode 100644 index 0000000..8a11602 --- /dev/null +++ b/scripts/reset-e2e.mjs @@ -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.`); diff --git a/src/components/katalog/catalog-grid.tsx b/src/components/katalog/catalog-grid.tsx index d64e288..cf58680 100644 --- a/src/components/katalog/catalog-grid.tsx +++ b/src/components/katalog/catalog-grid.tsx @@ -1,306 +1,311 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { useRef, useState, useTransition } from 'react'; -import { CheckCheck, Gift, Loader2, X } from 'lucide-react'; -import { toast } from 'sonner'; -import { cn } from 'cn'; -import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions'; -import type { CatalogRow } from '@/lib/queries/catalog'; -import { PRIORITY_LABELS } from '@/lib/wishlist-logic'; -import { SetCard, SetCardContent } from './set-card'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Field } from '@/components/ui/field'; -import { Input } from '@/components/ui/input'; -import { NativeSelect } from '@/components/ui/native-select'; - -const LONG_PRESS_MS = 450; -const NEW_LIST = '__neu__'; - -export type WishlistTarget = { id: string; title: string }; - -type Props = { - sets: CatalogRow[]; - wishlists: WishlistTarget[]; - /** Preselected target, used by the embedded view on a wishlist page. */ - defaultWishlistId?: string; - emptyState?: React.ReactNode; -}; - -/** - * The catalog grid with its selection mode. - * - * On a phone a long press starts the selection, afterwards a tap toggles. - * On a desktop the "Auswählen" button does the same job. Sets that are already - * in the collection cannot be selected - they do not belong on a wishlist. - */ -export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) { - const router = useRouter(); - const [selecting, setSelecting] = useState(false); - const [selected, setSelected] = useState>(new Set()); - const [dialogOpen, setDialogOpen] = useState(false); - const [targetId, setTargetId] = useState(defaultWishlistId ?? wishlists[0]?.id ?? NEW_LIST); - const [newTitle, setNewTitle] = useState(''); - const [priority, setPriority] = useState('2'); - const [pending, startTransition] = useTransition(); - - const longPressTimer = useRef | null>(null); - const longPressFired = useRef(false); - - const selectable = sets.filter((item) => item.ownedCopies === 0); - - function toggle(setId: string) { - setSelected((current) => { - const next = new Set(current); - if (next.has(setId)) next.delete(setId); - else next.add(setId); - return next; - }); - } - - function startLongPress(item: CatalogRow) { - if (item.ownedCopies > 0) return; - longPressFired.current = false; - longPressTimer.current = setTimeout(() => { - longPressFired.current = true; - setSelecting(true); - toggle(item.id); - // A short buzz confirms the mode switch where the device supports it. - navigator.vibrate?.(15); - }, LONG_PRESS_MS); - } - - function cancelLongPress() { - if (longPressTimer.current) clearTimeout(longPressTimer.current); - longPressTimer.current = null; - } - - function endSelection() { - setSelecting(false); - setSelected(new Set()); - } - - function submit() { - const setIds = [...selected]; - if (setIds.length === 0) return; - - startTransition(async () => { - let wishlistId = targetId; - - if (wishlistId === NEW_LIST) { - const created = await createWishlist({ title: newTitle, occasion: 'other' }); - if (!created.ok) { - toast.error(created.error); - return; - } - wishlistId = created.data.id; - } - - const result = await addSetsToWishlist({ wishlistId, setIds, priority }); - if (!result.ok) { - toast.error(result.error); - return; - } - - if (result.data.added === 0) toast.warning(result.data.message); - else toast.success(result.data.message); - - setDialogOpen(false); - endSelection(); - router.refresh(); - }); - } - - if (sets.length === 0) { - return ( -
- {emptyState ?? 'Keine Sets gefunden. Passe die Filter an oder leg ein neues Set an.'} -
- ); - } - - return ( -
-
-

- {selecting - ? 'Tippe die Sets an, die auf die Wunschliste sollen.' - : 'Tipp: Kachel lange drücken, um mehrere Sets auszuwählen.'} -

- {selecting ? ( - - ) : ( - - )} -
- -
- {sets.map((item) => { - const owned = item.ownedCopies > 0; - const isSelected = selected.has(item.id); - - if (!selecting) { - return ( -
startLongPress(item)} - onPointerUp={cancelLongPress} - onPointerLeave={cancelLongPress} - onPointerCancel={cancelLongPress} - onContextMenu={(event) => { - // The long press must not open the browser context menu. - if (longPressFired.current) event.preventDefault(); - }} - onClickCapture={(event) => { - if (longPressFired.current) { - event.preventDefault(); - event.stopPropagation(); - longPressFired.current = false; - } - }} - > - -
- ); - } - - return ( - - ); - })} -
- - {selecting ? ( -
-
- - {selected.size} ausgewählt - -
- -
-
- ) : null} - - - - - - {selected.size === 1 - ? '1 Set auf die Wunschliste' - : `${selected.size} Sets auf die Wunschliste`} - - - Sets, die schon auf der Liste oder bereits in der Sammlung sind, werden - übersprungen. - - - -
- - setTargetId(event.target.value)} - > - {wishlists.map((list) => ( - - ))} - - - - - {targetId === NEW_LIST ? ( - - setNewTitle(event.target.value)} - placeholder="Weihnachten 2026" - maxLength={80} - /> - - ) : null} - - - setPriority(event.target.value)} - > - {Object.entries(PRIORITY_LABELS).map(([value, label]) => ( - - ))} - - -
- - - - - -
-
-
- ); -} +'use client'; + +import { useRouter } from 'next/navigation'; +import { useRef, useState, useTransition } from 'react'; +import { CheckCheck, Gift, Loader2, X } from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from 'cn'; +import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions'; +import type { CatalogRow } from '@/lib/queries/catalog'; +import { PRIORITY_LABELS } from '@/lib/wishlist-logic'; +import { SetCard, SetCardContent } from './set-card'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; + +const LONG_PRESS_MS = 450; +const NEW_LIST = '__neu__'; + +export type WishlistTarget = { id: string; title: string }; + +type Props = { + sets: CatalogRow[]; + wishlists: WishlistTarget[]; + /** Preselected target, used by the embedded view on a wishlist page. */ + defaultWishlistId?: string; + emptyState?: React.ReactNode; +}; + +/** + * The catalog grid with its selection mode. + * + * On a phone a long press starts the selection, afterwards a tap toggles. + * On a desktop the "Auswählen" button does the same job. Sets that are already + * in the collection cannot be selected - they do not belong on a wishlist. + */ +export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) { + const router = useRouter(); + const [selecting, setSelecting] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [dialogOpen, setDialogOpen] = useState(false); + const [targetId, setTargetId] = useState(defaultWishlistId ?? wishlists[0]?.id ?? NEW_LIST); + const [newTitle, setNewTitle] = useState(''); + const [priority, setPriority] = useState('2'); + const [pending, startTransition] = useTransition(); + + const longPressTimer = useRef | null>(null); + const longPressFired = useRef(false); + + const selectable = sets.filter((item) => item.ownedCopies === 0); + + function toggle(setId: string) { + setSelected((current) => { + const next = new Set(current); + if (next.has(setId)) next.delete(setId); + else next.add(setId); + return next; + }); + } + + function startLongPress(item: CatalogRow) { + if (item.ownedCopies > 0) return; + longPressFired.current = false; + longPressTimer.current = setTimeout(() => { + longPressFired.current = true; + setSelecting(true); + toggle(item.id); + // A short buzz confirms the mode switch where the device supports it. + navigator.vibrate?.(15); + }, LONG_PRESS_MS); + } + + function cancelLongPress() { + if (longPressTimer.current) clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + + function endSelection() { + setSelecting(false); + setSelected(new Set()); + } + + function submit() { + const setIds = [...selected]; + if (setIds.length === 0) return; + + startTransition(async () => { + let wishlistId = targetId; + + if (wishlistId === NEW_LIST) { + // 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) { + toast.error(created.error); + return; + } + wishlistId = created.data.id; + } + + const result = await addSetsToWishlist({ wishlistId, setIds, priority }); + if (!result.ok) { + toast.error(result.error); + return; + } + + if (result.data.added === 0) toast.warning(result.data.message); + else toast.success(result.data.message); + + setDialogOpen(false); + endSelection(); + router.refresh(); + }); + } + + if (sets.length === 0) { + return ( +
+ {emptyState ?? 'Keine Sets gefunden. Passe die Filter an oder leg ein neues Set an.'} +
+ ); + } + + return ( +
+
+

+ {selecting + ? 'Tippe die Sets an, die auf die Wunschliste sollen.' + : 'Tipp: Kachel lange drücken, um mehrere Sets auszuwählen.'} +

+ {selecting ? ( + + ) : ( + + )} +
+ +
+ {sets.map((item) => { + const owned = item.ownedCopies > 0; + const isSelected = selected.has(item.id); + + if (!selecting) { + return ( +
startLongPress(item)} + onPointerUp={cancelLongPress} + onPointerLeave={cancelLongPress} + onPointerCancel={cancelLongPress} + onContextMenu={(event) => { + // The long press must not open the browser context menu. + if (longPressFired.current) event.preventDefault(); + }} + onClickCapture={(event) => { + if (longPressFired.current) { + event.preventDefault(); + event.stopPropagation(); + longPressFired.current = false; + } + }} + > + +
+ ); + } + + return ( + + ); + })} +
+ + {selecting ? ( +
+
+ + {selected.size} ausgewählt + +
+ +
+
+ ) : null} + + + + + + {selected.size === 1 + ? '1 Set auf die Wunschliste' + : `${selected.size} Sets auf die Wunschliste`} + + + Sets, die schon auf der Liste oder bereits in der Sammlung sind, werden + übersprungen. + + + +
+ + setTargetId(event.target.value)} + > + {wishlists.map((list) => ( + + ))} + + + + + {targetId === NEW_LIST ? ( + + setNewTitle(event.target.value)} + placeholder="Weihnachten 2026" + maxLength={80} + /> + + ) : null} + + + setPriority(event.target.value)} + > + {Object.entries(PRIORITY_LABELS).map(([value, label]) => ( + + ))} + + +
+ + + + + +
+
+
+ ); +} diff --git a/src/components/katalog/set-actions.tsx b/src/components/katalog/set-actions.tsx index b936745..3ec9f6d 100644 --- a/src/components/katalog/set-actions.tsx +++ b/src/components/katalog/set-actions.tsx @@ -1,216 +1,221 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { useState, useTransition } from 'react'; -import { Gift, Loader2, Plus, Trash2 } from 'lucide-react'; -import { toast } from 'sonner'; -import { deleteSet } from '@/app/(app)/katalog/actions'; -import { addSetToCollection } from '@/app/(app)/sammlung/actions'; -import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions'; -import { PRIORITY_LABELS } from '@/lib/wishlist-logic'; -import { Button } from '@/components/ui/button'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Field } from '@/components/ui/field'; -import { Input } from '@/components/ui/input'; -import { NativeSelect } from '@/components/ui/native-select'; - -const NEW_LIST = '__neu__'; - -export function SetActions({ - setId, - setName, - owned, - wishlists, -}: { - setId: string; - setName: string; - owned: boolean; - wishlists: Array<{ id: string; title: string }>; -}) { - const router = useRouter(); - const [pending, startTransition] = useTransition(); - const [wishlistOpen, setWishlistOpen] = useState(false); - const [deleteOpen, setDeleteOpen] = useState(false); - const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST); - const [newTitle, setNewTitle] = useState(''); - const [priority, setPriority] = useState('2'); - - function addToCollection() { - startTransition(async () => { - const result = await addSetToCollection(setId); - if (!result.ok) { - toast.error(result.error); - return; - } - toast.success('Zur Sammlung hinzugefügt.'); - router.refresh(); - }); - } - - function addToWishlist() { - startTransition(async () => { - let wishlistId = targetId; - - if (wishlistId === NEW_LIST) { - const created = await createWishlist({ title: newTitle, occasion: 'other' }); - if (!created.ok) { - toast.error(created.error); - return; - } - wishlistId = created.data.id; - } - - const result = await addSetsToWishlist({ wishlistId, setIds: [setId], priority }); - if (!result.ok) { - toast.error(result.error); - return; - } - - if (result.data.added === 0) toast.warning(result.data.message); - else toast.success('Auf die Wunschliste gesetzt.'); - - setWishlistOpen(false); - router.refresh(); - }); - } - - function remove() { - startTransition(async () => { - const result = await deleteSet(setId); - if (!result.ok) { - toast.error(result.error); - return; - } - toast.success('Set gelöscht.'); - router.push('/katalog'); - router.refresh(); - }); - } - - return ( -
- - - - - - - {owned ? ( -

- Sets aus der Sammlung lassen sich nicht auf eine Wunschliste setzen. -

- ) : null} - - - - - Auf Wunschliste setzen - {setName} - - -
- - setTargetId(event.target.value)} - > - {wishlists.map((list) => ( - - ))} - - - - - {targetId === NEW_LIST ? ( - - setNewTitle(event.target.value)} - placeholder="Weihnachten 2026" - maxLength={80} - /> - - ) : null} - - - setPriority(event.target.value)} - > - {Object.entries(PRIORITY_LABELS).map(([value, label]) => ( - - ))} - - -
- - - - - -
-
- - - - - Set wirklich löschen? - - „{setName}“ wird aus dem Katalog entfernt – mit allen Sammlungseinträgen, - Anleitungen, Fotos und Wunschlisten-Einträgen dazu. Das lässt sich nicht rückgängig - machen. - - - - Abbrechen - Löschen - - - -
- ); -} +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Gift, Loader2, Plus, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { deleteSet } from '@/app/(app)/katalog/actions'; +import { addSetToCollection } from '@/app/(app)/sammlung/actions'; +import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions'; +import { PRIORITY_LABELS } from '@/lib/wishlist-logic'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; + +const NEW_LIST = '__neu__'; + +export function SetActions({ + setId, + setName, + owned, + wishlists, +}: { + setId: string; + setName: string; + owned: boolean; + wishlists: Array<{ id: string; title: string }>; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [wishlistOpen, setWishlistOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST); + const [newTitle, setNewTitle] = useState(''); + const [priority, setPriority] = useState('2'); + + function addToCollection() { + startTransition(async () => { + const result = await addSetToCollection(setId); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Zur Sammlung hinzugefügt.'); + router.refresh(); + }); + } + + function addToWishlist() { + startTransition(async () => { + let wishlistId = targetId; + + if (wishlistId === NEW_LIST) { + // 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) { + toast.error(created.error); + return; + } + wishlistId = created.data.id; + } + + const result = await addSetsToWishlist({ wishlistId, setIds: [setId], priority }); + if (!result.ok) { + toast.error(result.error); + return; + } + + if (result.data.added === 0) toast.warning(result.data.message); + else toast.success('Auf die Wunschliste gesetzt.'); + + setWishlistOpen(false); + router.refresh(); + }); + } + + function remove() { + startTransition(async () => { + const result = await deleteSet(setId); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Set gelöscht.'); + router.push('/katalog'); + router.refresh(); + }); + } + + return ( +
+ + + + + + + {owned ? ( +

+ Sets aus der Sammlung lassen sich nicht auf eine Wunschliste setzen. +

+ ) : null} + + + + + Auf Wunschliste setzen + {setName} + + +
+ + setTargetId(event.target.value)} + > + {wishlists.map((list) => ( + + ))} + + + + + {targetId === NEW_LIST ? ( + + setNewTitle(event.target.value)} + placeholder="Weihnachten 2026" + maxLength={80} + /> + + ) : null} + + + setPriority(event.target.value)} + > + {Object.entries(PRIORITY_LABELS).map(([value, label]) => ( + + ))} + + +
+ + + + + +
+
+ + + + + Set wirklich löschen? + + „{setName}“ wird aus dem Katalog entfernt – mit allen Sammlungseinträgen, + Anleitungen, Fotos und Wunschlisten-Einträgen dazu. Das lässt sich nicht rückgängig + machen. + + + + Abbrechen + Löschen + + + +
+ ); +} diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx index 84b9414..ed50b66 100644 --- a/src/components/ui/field.tsx +++ b/src/components/ui/field.tsx @@ -1,30 +1,36 @@ -import { cn } from 'cn'; -import { Label } from './label'; - -/** Label + control + optional hint, the layout every form in the app uses. */ -export function Field({ - label, - htmlFor, - hint, - required, - className, - children, -}: { - label: string; - htmlFor: string; - hint?: string; - required?: boolean; - className?: string; - children: React.ReactNode; -}) { - return ( -
- - {children} - {hint ?

{hint}

: null} -
- ); -} +import { cn } from 'cn'; +import { Label } from './label'; + +/** Label + control + optional hint, the layout every form in the app uses. */ +export function Field({ + label, + htmlFor, + hint, + required, + className, + children, +}: { + label: string; + htmlFor: string; + hint?: string; + required?: boolean; + className?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint ?

{hint}

: null} +
+ ); +} diff --git a/src/components/wunschlisten/share-card.tsx b/src/components/wunschlisten/share-card.tsx index edcbe15..2455212 100644 --- a/src/components/wunschlisten/share-card.tsx +++ b/src/components/wunschlisten/share-card.tsx @@ -1,122 +1,128 @@ -'use client'; - -import { useState, useTransition } from 'react'; -import { Check, Copy, Link2Off, RefreshCw, Share2 } from 'lucide-react'; -import { toast } from 'sonner'; -import { rotateShareToken } from '@/app/(app)/wunschlisten/actions'; -import { Button } from '@/components/ui/button'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { Input } from '@/components/ui/input'; - -export function ShareCard({ - wishlistId, - shareToken, - isPublic, - title, -}: { - wishlistId: string; - shareToken: string; - isPublic: boolean; - title: string; -}) { - const [token, setToken] = useState(shareToken); - const [copied, setCopied] = useState(false); - const [rotateOpen, setRotateOpen] = useState(false); - const [pending, startTransition] = useTransition(); - - // Built in the browser so the link always matches the host actually used. - const url = typeof window === 'undefined' ? '' : `${window.location.origin}/w/${token}`; - - async function copy() { - try { - await navigator.clipboard.writeText(url); - setCopied(true); - toast.success('Link kopiert.'); - setTimeout(() => setCopied(false), 2000); - } catch { - toast.error('Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.'); - } - } - - async function share() { - if (!navigator.share) { - void copy(); - return; - } - try { - await navigator.share({ title, url }); - } catch { - /* the user cancelled the share sheet */ - } - } - - function rotate() { - startTransition(async () => { - const result = await rotateShareToken(wishlistId); - if (!result.ok) { - toast.error(result.error); - return; - } - setToken(result.data.token); - setRotateOpen(false); - toast.success('Neuer Link erzeugt. Der alte funktioniert nicht mehr.'); - }); - } - - if (!isPublic) { - return ( -
- - Das Teilen ist für diese Liste ausgeschaltet. Aktiviere es beim Bearbeiten der Liste. -
- ); - } - - return ( -
-

Link zum Teilen

-
- event.currentTarget.select()} className="font-mono text-xs" /> - - -
-

- Wer den Link hat, sieht die Liste – ohne Anmeldung. Sonst wird nichts preisgegeben. -

- - - - - - Neuen Link erzeugen? - - Der bisherige Link funktioniert danach nicht mehr. Bereits abgegebene - Reservierungen bleiben erhalten, aber die Storno-Links der Schenkenden werden - ungültig. - - - - Abbrechen - Neuen Link erzeugen - - - -
- ); -} +'use client'; + +import { useState, useTransition } from 'react'; +import { Check, Copy, Link2Off, RefreshCw, Share2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { rotateShareToken } from '@/app/(app)/wunschlisten/actions'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Input } from '@/components/ui/input'; + +export function ShareCard({ + wishlistId, + shareToken, + isPublic, + title, +}: { + wishlistId: string; + shareToken: string; + isPublic: boolean; + title: string; +}) { + const [token, setToken] = useState(shareToken); + const [copied, setCopied] = useState(false); + const [rotateOpen, setRotateOpen] = useState(false); + const [pending, startTransition] = useTransition(); + + // Built in the browser so the link always matches the host actually used. + const url = typeof window === 'undefined' ? '' : `${window.location.origin}/w/${token}`; + + async function copy() { + try { + await navigator.clipboard.writeText(url); + setCopied(true); + toast.success('Link kopiert.'); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error('Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.'); + } + } + + async function share() { + if (!navigator.share) { + void copy(); + return; + } + try { + await navigator.share({ title, url }); + } catch { + /* the user cancelled the share sheet */ + } + } + + function rotate() { + startTransition(async () => { + const result = await rotateShareToken(wishlistId); + if (!result.ok) { + toast.error(result.error); + return; + } + setToken(result.data.token); + setRotateOpen(false); + toast.success('Neuer Link erzeugt. Der alte funktioniert nicht mehr.'); + }); + } + + if (!isPublic) { + return ( +
+ + Das Teilen ist für diese Liste ausgeschaltet. Aktiviere es beim Bearbeiten der Liste. +
+ ); + } + + return ( +
+

Link zum Teilen

+
+ event.currentTarget.select()} + className="font-mono text-xs" + /> + + +
+

+ Wer den Link hat, sieht die Liste – ohne Anmeldung. Sonst wird nichts preisgegeben. +

+ + + + + + Neuen Link erzeugen? + + Der bisherige Link funktioniert danach nicht mehr. Bereits abgegebene + Reservierungen bleiben erhalten, aber die Storno-Links der Schenkenden werden + ungültig. + + + + Abbrechen + Neuen Link erzeugen + + + +
+ ); +} diff --git a/tests/unit/money.test.ts b/tests/unit/money.test.ts new file mode 100644 index 0000000..256d388 --- /dev/null +++ b/tests/unit/money.test.ts @@ -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); + }); +}); diff --git a/tests/unit/ownership.test.ts b/tests/unit/ownership.test.ts new file mode 100644 index 0000000..7cedab2 --- /dev/null +++ b/tests/unit/ownership.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/slug.test.ts b/tests/unit/slug.test.ts new file mode 100644 index 0000000..2b2ca6e --- /dev/null +++ b/tests/unit/slug.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/tokens.test.ts b/tests/unit/tokens.test.ts new file mode 100644 index 0000000..1205ab0 --- /dev/null +++ b/tests/unit/tokens.test.ts @@ -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); + }); +}); diff --git a/tests/unit/wishlist-logic.test.ts b/tests/unit/wishlist-logic.test.ts new file mode 100644 index 0000000..6515817 --- /dev/null +++ b/tests/unit/wishlist-logic.test.ts @@ -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'); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..c058104 --- /dev/null +++ b/vitest.config.mts @@ -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, + }, +});