From baa94bf7e5a6aa30b9abcdd708417905b6d73478 Mon Sep 17 00:00:00 2001
From: unknown
Date: Mon, 7 Sep 2026 07:15:41 +0200
Subject: [PATCH] feat(scanner): ean-barcode mit der handykamera scannen
Knopf im Katalog neben "Set anlegen": Strichcode ins Bild halten, und
entweder oeffnet sich das passende Set oder das Formular "Neues Set"
mit ausgefuellter EAN. Dekodiert wird im Browser mit zxing, das nur
beim Oeffnen des Scanners nachgeladen wird - nichts verlaesst das
Geraet, und die Katalogseite bleibt leicht fuer alle, die nie scannen.
Jeder gelesene Code wird gegen seine Pruefziffer geprueft, bevor er
verwendet wird. Ohne das wuerde eine falsch erkannte Ziffer stillschweigend
ein zweites Katalog-Set fuer ein Set anlegen, das es schon gibt; bei
einem Fehlversuch scannt die Kamera einfach weiter.
Die Kamera braucht wie Passkeys einen sicheren Kontext. Fehlt HTTPS oder
wird der Zugriff abgelehnt, steht das als deutscher Hinweis im Dialog
statt einer stummen schwarzen Flaeche.
8 Unit-Tests mit echten EAN-13-, EAN-8- und UPC-A-Beispielen.
Refs Phase 2
Co-Authored-By: Claude Opus 5 (1M context)
---
package.json | 148 +++++++--------
pnpm-lock.yaml | 39 ++++
src/app/(app)/katalog/page.tsx | 16 +-
src/app/(app)/katalog/scan-actions.ts | 37 ++++
src/components/katalog/barcode-scanner.tsx | 199 +++++++++++++++++++++
src/lib/ean.ts | 41 +++++
src/lib/queries/catalog.ts | 18 ++
tests/setup-env.ts | 2 +-
tests/unit/ean.test.ts | 48 +++++
9 files changed, 468 insertions(+), 80 deletions(-)
create mode 100644 src/app/(app)/katalog/scan-actions.ts
create mode 100644 src/components/katalog/barcode-scanner.tsx
create mode 100644 src/lib/ean.ts
create mode 100644 tests/unit/ean.test.ts
diff --git a/package.json b/package.json
index 49f9650..e24d09e 100644
--- a/package.json
+++ b/package.json
@@ -1,73 +1,75 @@
-{
- "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": "node scripts/screenshots-prepare.mjs && 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",
- "esbuild": "^0.25.12",
- "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": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
+ },
+ "dependencies": {
+ "@better-auth/passkey": "^1.7.3",
+ "@zxing/browser": "^0.2.1",
+ "@zxing/library": "^0.23.0",
+ "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",
+ "esbuild": "^0.25.12",
+ "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/pnpm-lock.yaml b/pnpm-lock.yaml
index fa513f6..7386ebd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,12 @@ importers:
'@better-auth/passkey':
specifier: ^1.7.3
version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)
+ '@zxing/browser':
+ specifier: ^0.2.1
+ version: 0.2.1(@zxing/library@0.23.0)
+ '@zxing/library':
+ specifier: ^0.23.0
+ version: 0.23.0
better-auth:
specifier: ^1.7.3
version: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)))
@@ -2457,6 +2463,18 @@ packages:
'@vitest/spy@5.0.0':
resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==}
+ '@zxing/browser@0.2.1':
+ resolution: {integrity: sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==}
+ peerDependencies:
+ '@zxing/library': ^0.23.0
+
+ '@zxing/library@0.23.0':
+ resolution: {integrity: sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==}
+ engines: {node: '>= 24.0.0'}
+
+ '@zxing/text-encoding@0.9.0':
+ resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
+
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -4745,6 +4763,10 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
+ ts-custom-error@3.3.1:
+ resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==}
+ engines: {node: '>=14.0.0'}
+
ts-morph@26.0.0:
resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==}
@@ -7104,6 +7126,21 @@ snapshots:
'@vitest/spy@5.0.0': {}
+ '@zxing/browser@0.2.1(@zxing/library@0.23.0)':
+ dependencies:
+ '@zxing/library': 0.23.0
+ optionalDependencies:
+ '@zxing/text-encoding': 0.9.0
+
+ '@zxing/library@0.23.0':
+ dependencies:
+ ts-custom-error: 3.3.1
+ optionalDependencies:
+ '@zxing/text-encoding': 0.9.0
+
+ '@zxing/text-encoding@0.9.0':
+ optional: true
+
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -9555,6 +9592,8 @@ snapshots:
dependencies:
typescript: 5.9.3
+ ts-custom-error@3.3.1: {}
+
ts-morph@26.0.0:
dependencies:
'@ts-morph/common': 0.27.0
diff --git a/src/app/(app)/katalog/page.tsx b/src/app/(app)/katalog/page.tsx
index 1098964..b3f7f4d 100644
--- a/src/app/(app)/katalog/page.tsx
+++ b/src/app/(app)/katalog/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Plus } from 'lucide-react';
+import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid';
import { Pagination } from '@/components/katalog/pagination';
@@ -60,12 +61,15 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
-
+
+
+
+
diff --git a/src/app/(app)/katalog/scan-actions.ts b/src/app/(app)/katalog/scan-actions.ts
new file mode 100644
index 0000000..b07163e
--- /dev/null
+++ b/src/app/(app)/katalog/scan-actions.ts
@@ -0,0 +1,37 @@
+'use server';
+
+import { actionData, actionError, type ActionResult } from '@/lib/action-result';
+import { isValidEan } from '@/lib/ean';
+import { findSetByEan } from '@/lib/queries/catalog';
+import { requireUser } from '@/lib/session';
+
+export type ScanLookup =
+ | { found: true; setId: string; setNumber: string; name: string }
+ | { found: false; ean: string };
+
+/**
+ * Resolves a scanned barcode.
+ *
+ * Either the set is already in the catalog - then we jump straight to it - or
+ * it is not, and the caller opens the "new set" form with the number filled in.
+ */
+export async function lookupScannedEan(rawEan: unknown): Promise> {
+ await requireUser();
+
+ const ean = typeof rawEan === 'string' ? rawEan.replace(/\D/g, '') : '';
+ if (!isValidEan(ean)) {
+ return actionError('Der Strichcode wurde nicht sauber erkannt. Bitte noch einmal scannen.');
+ }
+
+ const match = findSetByEan(ean);
+ if (match) {
+ return actionData({
+ found: true as const,
+ setId: match.id,
+ setNumber: match.setNumber,
+ name: match.name,
+ });
+ }
+
+ return actionData({ found: false as const, ean });
+}
diff --git a/src/components/katalog/barcode-scanner.tsx b/src/components/katalog/barcode-scanner.tsx
new file mode 100644
index 0000000..f5fa78f
--- /dev/null
+++ b/src/components/katalog/barcode-scanner.tsx
@@ -0,0 +1,199 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Loader2, ScanLine } from 'lucide-react';
+import { toast } from 'sonner';
+import { lookupScannedEan } from '@/app/(app)/katalog/scan-actions';
+import { formatEan, isValidEan } from '@/lib/ean';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+
+type Controls = { stop: () => void };
+
+/**
+ * Scans the barcode on a box with the phone camera.
+ *
+ * Decoding runs in the browser with zxing, which is bundled with the app and
+ * loaded only when the scanner is opened - nothing leaves the device, and the
+ * catalog pages stay light for everyone who never scans.
+ */
+export function BarcodeScannerButton() {
+ const router = useRouter();
+ const videoRef = useRef(null);
+ const controlsRef = useRef(null);
+ const handledRef = useRef(false);
+
+ const [open, setOpen] = useState(false);
+ const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting');
+ const [message, setMessage] = useState(null);
+
+ const stop = useCallback(() => {
+ controlsRef.current?.stop();
+ controlsRef.current = null;
+ }, []);
+
+ const onDetected = useCallback(
+ (raw: string) => {
+ if (handledRef.current) return;
+ if (!isValidEan(raw)) return; // keep scanning on a misread
+
+ handledRef.current = true;
+ stop();
+ setStatus('checking');
+
+ void lookupScannedEan(raw).then((result) => {
+ if (!result.ok) {
+ toast.error(result.error);
+ setOpen(false);
+ return;
+ }
+
+ setOpen(false);
+
+ if (result.data.found) {
+ toast.success(`${result.data.setNumber} · ${result.data.name}`);
+ router.push(`/katalog/${result.data.setId}`);
+ } else {
+ toast.info(`${formatEan(result.data.ean)} ist noch nicht im Katalog.`);
+ router.push(`/katalog/neu?ean=${encodeURIComponent(result.data.ean)}`);
+ }
+ router.refresh();
+ });
+ },
+ [router, stop],
+ );
+
+ useEffect(() => {
+ if (!open) return;
+
+ handledRef.current = false;
+ setStatus('starting');
+ setMessage(null);
+
+ // The camera needs a secure context - same rule as for passkeys.
+ if (!window.isSecureContext) {
+ setStatus('error');
+ setMessage(
+ 'Die Kamera funktioniert nur über HTTPS. Ruf Bricktrack über deine Domain auf, nicht über die IP-Adresse.',
+ );
+ return;
+ }
+
+ let cancelled = false;
+
+ void (async () => {
+ try {
+ const { BrowserMultiFormatReader } = await import('@zxing/browser');
+ const reader = new BrowserMultiFormatReader();
+ const video = videoRef.current;
+ if (!video || cancelled) return;
+
+ const controls = await reader.decodeFromConstraints(
+ // The rear camera is the one pointing at the box.
+ { video: { facingMode: 'environment' } },
+ video,
+ (result) => {
+ if (result) onDetected(result.getText());
+ },
+ );
+
+ if (cancelled) {
+ controls.stop();
+ return;
+ }
+
+ controlsRef.current = controls;
+ setStatus('scanning');
+ } catch (error) {
+ if (cancelled) return;
+ console.error('[scan] Kamera konnte nicht gestartet werden', error);
+ setStatus('error');
+
+ const name = (error as { name?: string })?.name;
+ setMessage(
+ name === 'NotAllowedError'
+ ? 'Der Zugriff auf die Kamera wurde abgelehnt. Erlaube ihn in den Browser-Einstellungen und versuch es noch einmal.'
+ : name === 'NotFoundError'
+ ? 'Dieses Gerät hat keine nutzbare Kamera.'
+ : 'Die Kamera lässt sich nicht starten.',
+ );
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ stop();
+ };
+ }, [open, onDetected, stop]);
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/lib/ean.ts b/src/lib/ean.ts
new file mode 100644
index 0000000..4dac099
--- /dev/null
+++ b/src/lib/ean.ts
@@ -0,0 +1,41 @@
+/**
+ * Barcode helpers.
+ *
+ * Kept free of server-only imports so both the scanner in the browser and the
+ * lookup on the server use the very same rules.
+ */
+
+/** Digits only - scanners and humans disagree about spaces and dashes. */
+export function normalizeEan(input: string): string {
+ return input.replace(/\D/g, '');
+}
+
+/**
+ * Checks an EAN-8, EAN-13 or UPC-A against its check digit.
+ *
+ * Worth doing: a camera occasionally reads a digit wrong, and without this a
+ * misread would silently create a second catalog entry for a set that is
+ * already there.
+ */
+export function isValidEan(input: string): boolean {
+ const digits = normalizeEan(input);
+ if (![8, 12, 13].includes(digits.length)) return false;
+
+ // From the right: every second digit counts three times.
+ const checkDigit = Number(digits[digits.length - 1]);
+ const body = digits.slice(0, -1);
+
+ let sum = 0;
+ for (let index = body.length - 1, weight = 3; index >= 0; index -= 1, weight = weight === 3 ? 1 : 3) {
+ sum += Number(body[index]) * weight;
+ }
+
+ return (10 - (sum % 10)) % 10 === checkDigit;
+}
+
+/** "4005556603985" -> "4 005556 603985" for readable display. */
+export function formatEan(input: string): string {
+ const digits = normalizeEan(input);
+ if (digits.length !== 13) return digits;
+ return `${digits.slice(0, 1)} ${digits.slice(1, 7)} ${digits.slice(7)}`;
+}
diff --git a/src/lib/queries/catalog.ts b/src/lib/queries/catalog.ts
index 9bb2fd2..96b45ff 100644
--- a/src/lib/queries/catalog.ts
+++ b/src/lib/queries/catalog.ts
@@ -160,6 +160,24 @@ export function getSetById(id: string) {
return row ?? null;
}
+/**
+ * Lookup by barcode. EANs are stored as typed, so the comparison strips
+ * everything but digits on both sides - a scanner never produces the spaces
+ * and dashes people write by hand.
+ */
+export function findSetByEan(ean: string) {
+ const digits = ean.replace(/\D/g, '');
+ if (digits.length === 0) return null;
+
+ return (
+ db
+ .select({ id: set.id, setNumber: set.setNumber, name: set.name })
+ .from(set)
+ .where(sql`replace(replace(coalesce(${set.ean}, ''), '-', ''), ' ', '') = ${digits}`)
+ .get() ?? null
+ );
+}
+
export function getSetByNumber(manufacturerId: string, setNumber: string) {
return (
db
diff --git a/tests/setup-env.ts b/tests/setup-env.ts
index b19663f..702ce79 100644
--- a/tests/setup-env.ts
+++ b/tests/setup-env.ts
@@ -10,7 +10,7 @@
* public base path and sets it to "/", which is not a URL. The clash only
* exists under Vite-based tooling, never in the app itself.
*/
-process.env.NODE_ENV ??= 'test';
+// NODE_ENV is typed read-only; vitest already sets it to 'test'.
process.env.BASE_URL = 'http://localhost:3000';
process.env.BETTER_AUTH_SECRET =
process.env.BETTER_AUTH_SECRET || 'test-secret-mindestens-32-zeichen-lang-0123';
diff --git a/tests/unit/ean.test.ts b/tests/unit/ean.test.ts
new file mode 100644
index 0000000..44ba727
--- /dev/null
+++ b/tests/unit/ean.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+import { formatEan, isValidEan, normalizeEan } from '@/lib/ean';
+
+describe('EAN normalisieren', () => {
+ it('behaelt nur Ziffern', () => {
+ expect(normalizeEan('4 005556 603985')).toBe('4005556603985');
+ expect(normalizeEan('4005556-603985')).toBe('4005556603985');
+ });
+});
+
+describe('EAN pruefen', () => {
+ it('akzeptiert gueltige EAN-13', () => {
+ // Playmobil 70392 und ein Lego-Beispiel.
+ expect(isValidEan('4008789703927')).toBe(true);
+ expect(isValidEan('5702017152677')).toBe(true);
+ });
+
+ it('akzeptiert gueltige EAN-8 und UPC-A', () => {
+ expect(isValidEan('96385074')).toBe(true);
+ expect(isValidEan('036000291452')).toBe(true);
+ });
+
+ it('lehnt eine falsche Pruefziffer ab', () => {
+ // Letzte Ziffer verdreht - genau der Fall, den eine Kamera produziert.
+ expect(isValidEan('4008789703928')).toBe(false);
+ expect(isValidEan('5702017152678')).toBe(false);
+ });
+
+ it('lehnt falsche Laengen ab', () => {
+ expect(isValidEan('')).toBe(false);
+ expect(isValidEan('123')).toBe(false);
+ expect(isValidEan('40087897039271')).toBe(false);
+ });
+
+ it('ignoriert Trennzeichen bei der Pruefung', () => {
+ expect(isValidEan('4 008789 703927')).toBe(true);
+ });
+});
+
+describe('EAN anzeigen', () => {
+ it('gruppiert eine EAN-13 lesbar', () => {
+ expect(formatEan('4008789703927')).toBe('4 008789 703927');
+ });
+
+ it('laesst andere Laengen unveraendert', () => {
+ expect(formatEan('96385074')).toBe('96385074');
+ });
+});