diff --git a/.env.example b/.env.example index 1306964..75817ac 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,8 @@ TZ=Europe/Berlin # BIND_ADDRESS=127.0.0.1 # HOST_PORT=3000 -# Optional (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import. -# Ohne Key funktioniert alles manuell weiter. +# Optional: Rebrickable-API-Key fuer den Lego-Import. Damit reicht auf der +# Seite "Neues Set" die Setnummer, und Name, Jahr, Teile, Minifiguren, Thema +# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter. +# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API) # REBRICKABLE_API_KEY= diff --git a/src/app/(app)/katalog/import-actions.ts b/src/app/(app)/katalog/import-actions.ts new file mode 100644 index 0000000..c8b9aee --- /dev/null +++ b/src/app/(app)/katalog/import-actions.ts @@ -0,0 +1,237 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { and, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { instruction, manufacturer, set, theme } from '@/db/schema'; +import { actionData, actionError, type ActionResult } from '@/lib/action-result'; +import { + fetchImageBytes, + fetchMinifigCount, + fetchSet, + fetchTheme, + isRebrickableEnabled, + legoInstructionsUrl, + RebrickableError, +} from '@/lib/rebrickable'; +import { requireUser } from '@/lib/session'; +import { uniqueSlug } from '@/lib/slug'; +import { storeImageBytes, UploadError } from '@/lib/uploads'; + +/** + * Import of a Lego set from Rebrickable. + * + * Runs entirely on the server, creates the manufacturer and the theme if they + * are missing, and stores the picture locally so the catalog keeps working + * when Rebrickable changes it. + */ + +const LEGO_SLUG = 'lego'; + +function now() { + return new Date(); +} + +/** Returns the Lego manufacturer, creating it on first import. */ +function ensureLegoManufacturer(): string { + const existing = db + .select({ id: manufacturer.id }) + .from(manufacturer) + .where(eq(manufacturer.slug, LEGO_SLUG)) + .get(); + if (existing) return existing.id; + + const taken = db.select({ slug: manufacturer.slug }).from(manufacturer).all(); + const id = crypto.randomUUID(); + + db.insert(manufacturer) + .values({ + id, + name: 'Lego', + slug: uniqueSlug('Lego', taken.map((row) => row.slug)), + website: 'https://www.lego.com/de-de', + createdAt: now(), + updatedAt: now(), + }) + .run(); + + return id; +} + +/** + * Maps a Rebrickable theme onto a local one, creating it (and its parent) when + * needed. Rebrickable nests themes deeply; one level of parent is enough to + * keep "Technic > Cars" readable without importing the whole tree. + */ +async function ensureTheme(manufacturerId: string, themeId: number | null): Promise { + if (themeId === null) return null; + + const remote = await fetchTheme(themeId); + if (!remote) return null; + + const existing = db + .select({ id: theme.id }) + .from(theme) + .where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, remote.name))) + .get(); + if (existing) return existing.id; + + let parentThemeId: string | null = null; + if (remote.parentId !== null) { + const parent = await fetchTheme(remote.parentId); + if (parent) { + const existingParent = db + .select({ id: theme.id }) + .from(theme) + .where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, parent.name))) + .get(); + + if (existingParent) { + parentThemeId = existingParent.id; + } else { + parentThemeId = crypto.randomUUID(); + db.insert(theme) + .values({ + id: parentThemeId, + manufacturerId, + name: parent.name, + createdAt: now(), + updatedAt: now(), + }) + .run(); + } + } + } + + const id = crypto.randomUUID(); + db.insert(theme) + .values({ + id, + manufacturerId, + name: remote.name, + parentThemeId, + createdAt: now(), + updatedAt: now(), + }) + .run(); + + return id; +} + +export type ImportResult = { + setId: string; + setNumber: string; + name: string; + imported: { + image: boolean; + theme: boolean; + minifigs: boolean; + }; +}; + +export async function importSetFromRebrickable( + rawSetNumber: unknown, +): Promise> { + await requireUser(); + + if (!isRebrickableEnabled()) { + return actionError( + 'Der Import ist nicht eingerichtet. Trag REBRICKABLE_API_KEY in der .env ein und starte den Container neu.', + ); + } + + const setNumber = typeof rawSetNumber === 'string' ? rawSetNumber.trim() : ''; + if (!setNumber) return actionError('Bitte gib eine Setnummer ein.'); + if (setNumber.length > 40) return actionError('Das sieht nicht nach einer Setnummer aus.'); + + let remote; + try { + remote = await fetchSet(setNumber); + } catch (error) { + if (error instanceof RebrickableError) return actionError(error.message); + throw error; + } + + if (!remote) { + return actionError( + `Rebrickable kennt die Setnummer ${setNumber} nicht. Du kannst das Set von Hand anlegen.`, + ); + } + + const manufacturerId = ensureLegoManufacturer(); + + const duplicate = db + .select({ id: set.id }) + .from(set) + .where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, remote.setNumber))) + .get(); + if (duplicate) { + return actionError(`${remote.setNumber} ist schon im Katalog: ${remote.name}.`); + } + + const themeId = await ensureTheme(manufacturerId, remote.themeId); + const minifigCount = await fetchMinifigCount(remote.setNumber); + + const setId = crypto.randomUUID(); + + // Cache the picture locally; a failed download must not fail the import. + let imagePath: string | null = null; + if (remote.imageUrl) { + const bytes = await fetchImageBytes(remote.imageUrl); + if (bytes) { + try { + imagePath = (await storeImageBytes(bytes, 'set-image', setId)).filePath; + } catch (error) { + if (!(error instanceof UploadError)) throw error; + console.error('[import] Bild konnte nicht gespeichert werden', error); + } + } + } + + db.insert(set) + .values({ + id: setId, + manufacturerId, + themeId, + setNumber: remote.setNumber, + name: remote.name, + year: remote.year, + pieceCount: remote.pieceCount, + minifigCount, + imagePath, + // Keep the source URL as a fallback if the local file is ever lost. + imageUrl: imagePath ? null : remote.imageUrl, + externalIds: { rebrickable: `${remote.setNumber}-1` }, + createdAt: now(), + updatedAt: now(), + }) + .run(); + + // Rebrickable does not serve instruction files, so this points at Lego's + // own service page for the set number. + db.insert(instruction) + .values({ + id: crypto.randomUUID(), + setId, + kind: 'external_url', + url: legoInstructionsUrl(remote.setNumber), + label: 'Bauanleitung bei LEGO', + sortOrder: 0, + createdAt: now(), + }) + .run(); + + revalidatePath('/katalog'); + revalidatePath('/verwaltung/themen'); + + return actionData({ + setId, + setNumber: remote.setNumber, + name: remote.name, + imported: { + image: imagePath !== null, + theme: themeId !== null, + minifigs: minifigCount !== null, + }, + }); +} diff --git a/src/app/(app)/katalog/neu/page.tsx b/src/app/(app)/katalog/neu/page.tsx index 07b544a..8abdeab 100644 --- a/src/app/(app)/katalog/neu/page.tsx +++ b/src/app/(app)/katalog/neu/page.tsx @@ -1,8 +1,10 @@ import type { Metadata } from 'next'; import Link from 'next/link'; +import { RebrickableImport } from '@/components/katalog/rebrickable-import'; import { SetForm } from '@/components/katalog/set-form'; import { Button } from '@/components/ui/button'; import { listManufacturers, listThemes } from '@/lib/queries/master-data'; +import { isRebrickableEnabled } from '@/lib/rebrickable'; import { requireUser } from '@/lib/session'; export const metadata: Metadata = { title: 'Neues Set' }; @@ -21,12 +23,19 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea manufacturerId: row.manufacturerId, })); + const importEnabled = isRebrickableEnabled(); + if (manufacturers.length === 0) { return ( -
+

Neues Set

+ + {/* The Lego import creates its manufacturer itself, so it works even + on a completely empty instance. */} + +

- Jedes Set gehört zu einem Hersteller. Leg zuerst einen an – Lego, Playmobil, Cobi oder + Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder was auch immer im Kinderzimmer steht.

+ + +
+ + oder von Hand + +
+
); diff --git a/src/components/katalog/rebrickable-import.tsx b/src/components/katalog/rebrickable-import.tsx new file mode 100644 index 0000000..ff0ce18 --- /dev/null +++ b/src/components/katalog/rebrickable-import.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Download, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { importSetFromRebrickable } from '@/app/(app)/katalog/import-actions'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; + +/** + * One-field Lego import: type the number from the box, get a finished catalog + * entry. Hidden entirely when no API key is configured. + */ +export function RebrickableImport({ enabled }: { enabled: boolean }) { + const router = useRouter(); + const [setNumber, setSetNumber] = useState(''); + const [pending, startTransition] = useTransition(); + + if (!enabled) { + return ( + + + Lego-Import + + Mit einem Rebrickable-API-Key füllt sich das Formular von selbst: Setnummer eingeben, + und Name, Jahr, Teile, Thema und Bild kommen automatisch. Dafür{' '} + REBRICKABLE_API_KEY in die{' '} + .env eintragen und den Container neu + starten. + + + + ); + } + + function submit(event: React.FormEvent) { + event.preventDefault(); + + startTransition(async () => { + const result = await importSetFromRebrickable(setNumber); + if (!result.ok) { + toast.error(result.error); + return; + } + + const missing = [ + result.data.imported.image ? null : 'Bild', + result.data.imported.theme ? null : 'Thema', + ].filter(Boolean); + + toast.success( + missing.length === 0 + ? `${result.data.setNumber} „${result.data.name}“ übernommen.` + : `${result.data.setNumber} übernommen – ohne ${missing.join(' und ')}.`, + ); + + router.push(`/katalog/${result.data.setId}`); + router.refresh(); + }); + } + + return ( + + + Lego-Import + + Setnummer von der Packung eintippen – Name, Jahr, Teile, Minifiguren, Thema und Bild + werden übernommen. Das Bild wird lokal gespeichert. + + + +
+ + setSetNumber(event.target.value)} + placeholder="60398" + inputMode="numeric" + maxLength={40} + className="font-mono" + /> + + +
+
+
+ ); +} diff --git a/src/env.ts b/src/env.ts index daa678b..ffb1f45 100644 --- a/src/env.ts +++ b/src/env.ts @@ -31,8 +31,23 @@ const schema = z.object({ REBRICKABLE_API_KEY: z.string().min(1).optional(), }); +/** + * An empty value counts as "not set". + * + * A line like `BASE_URL=` in a .env file is a common slip, and so is an empty + * variable injected by a container runtime. Falling back to the default beats + * failing to start with "Invalid URL". + */ +function withoutEmptyValues(source: NodeJS.ProcessEnv): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (typeof value === 'string' && value.trim() !== '') result[key] = value; + } + return result; +} + function parseEnv() { - const parsed = schema.safeParse(process.env); + const parsed = schema.safeParse(withoutEmptyValues(process.env)); if (!parsed.success) { const issues = parsed.error.issues .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`) diff --git a/src/lib/rebrickable.ts b/src/lib/rebrickable.ts new file mode 100644 index 0000000..8862415 --- /dev/null +++ b/src/lib/rebrickable.ts @@ -0,0 +1,140 @@ +import 'server-only'; +import { env } from '@/env'; +import { displaySetNumber, normalizeSetNumber } from './set-number'; + +export { displaySetNumber, legoInstructionsUrl, normalizeSetNumber } from './set-number'; + +/** + * Rebrickable lookup for Lego sets. + * + * Entirely optional: without REBRICKABLE_API_KEY the import stays switched + * off and everything keeps working by hand. Every request runs here on the + * server - the browser never talks to rebrickable.com. + */ + +const API_BASE = 'https://rebrickable.com/api/v3/lego'; +const REQUEST_TIMEOUT_MS = 10_000; + +export function isRebrickableEnabled(): boolean { + return Boolean(env.REBRICKABLE_API_KEY); +} + +export class RebrickableError extends Error {} + +export type RebrickableSet = { + setNumber: string; + name: string; + year: number | null; + pieceCount: number | null; + themeId: number | null; + imageUrl: string | null; + externalUrl: string | null; +}; + +export type RebrickableTheme = { + id: number; + name: string; + parentId: number | null; +}; + +async function request(path: string): Promise { + const key = env.REBRICKABLE_API_KEY; + if (!key) throw new RebrickableError('Für den Import fehlt der Rebrickable-API-Key.'); + + let response: Response; + try { + response = await fetch(`${API_BASE}${path}`, { + headers: { + Authorization: `key ${key}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + // The catalog is not a moving target; a day of caching is plenty. + next: { revalidate: 86_400 }, + }); + } catch (error) { + console.error('[rebrickable] Anfrage fehlgeschlagen', path, error); + throw new RebrickableError('Rebrickable ist gerade nicht erreichbar.'); + } + + if (response.status === 404) return null; + if (response.status === 401 || response.status === 403) { + throw new RebrickableError('Der Rebrickable-API-Key wird abgelehnt. Bitte prüf ihn.'); + } + if (response.status === 429) { + throw new RebrickableError('Rebrickable drosselt gerade. Bitte in einer Minute noch einmal.'); + } + if (!response.ok) { + throw new RebrickableError(`Rebrickable antwortet mit Status ${response.status}.`); + } + + return (await response.json()) as T; +} + +type SetResponse = { + set_num: string; + name: string; + year: number | null; + theme_id: number | null; + num_parts: number | null; + set_img_url: string | null; + set_url: string | null; +}; + +export async function fetchSet(setNumber: string): Promise { + const data = await request(`/sets/${encodeURIComponent(normalizeSetNumber(setNumber))}/`); + if (!data) return null; + + return { + setNumber: displaySetNumber(data.set_num), + name: data.name, + year: data.year ?? null, + pieceCount: data.num_parts ?? null, + themeId: data.theme_id ?? null, + imageUrl: data.set_img_url ?? null, + externalUrl: data.set_url ?? null, + }; +} + +type ThemeResponse = { id: number; name: string; parent_id: number | null }; + +export async function fetchTheme(themeId: number): Promise { + const data = await request(`/themes/${themeId}/`); + if (!data) return null; + return { id: data.id, name: data.name, parentId: data.parent_id ?? null }; +} + +type MinifigsResponse = { results: Array<{ quantity: number | null }> }; + +/** Number of minifigures, or null when the lookup does not work out. */ +export async function fetchMinifigCount(setNumber: string): Promise { + try { + const data = await request( + `/sets/${encodeURIComponent(normalizeSetNumber(setNumber))}/minifigs/`, + ); + if (!data) return null; + return data.results.reduce((total, row) => total + (row.quantity ?? 0), 0); + } catch { + // A missing minifig list must not fail the whole import. + return null; + } +} + +/** + * Downloads the set image so it keeps working when Rebrickable changes or + * removes it. Returns null instead of throwing: an import without a picture + * is still a good import. + */ +export async function fetchImageBytes(url: string): Promise { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (!response.ok) return null; + + const buffer = Buffer.from(await response.arrayBuffer()); + return buffer.byteLength > 0 ? buffer : null; + } catch (error) { + console.error('[rebrickable] Bild konnte nicht geladen werden', url, error); + return null; + } +} + diff --git a/src/lib/set-number.ts b/src/lib/set-number.ts new file mode 100644 index 0000000..66cb89f --- /dev/null +++ b/src/lib/set-number.ts @@ -0,0 +1,32 @@ +/** + * Set-number handling for the Lego import. + * + * Deliberately free of server-only imports so it can be unit tested and, if + * ever needed, used on the client. + */ + +/** + * Rebrickable numbers a set's variant explicitly: the box says "60398", the + * API knows "60398-1". People type what is printed on the box, so the suffix + * is added when it is missing. + */ +export function normalizeSetNumber(input: string): string { + const trimmed = input.trim().toUpperCase(); + return /-\d+$/.test(trimmed) ? trimmed : `${trimmed}-1`; +} + +/** The number as it should be stored and shown: without the "-1" variant. */ +export function displaySetNumber(setNum: string): string { + return setNum.trim().replace(/-1$/, ''); +} + +/** + * Lego publishes building instructions per set number. Rebrickable's API does + * not expose instruction files, so the import links to Lego's own service page + * rather than pretending to have the PDF. + */ +export function legoInstructionsUrl(setNumber: string): string { + return `https://www.lego.com/de-de/service/building-instructions/${encodeURIComponent( + displaySetNumber(setNumber), + )}`; +} diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts index 2012e79..6acd7b4 100644 --- a/src/lib/uploads.ts +++ b/src/lib/uploads.ts @@ -88,8 +88,25 @@ export type StoredImage = { */ export async function storeImage(file: File, kind: UploadKind, ownerId: string): Promise { assertSize(file); + return storeImageBytes(Buffer.from(await file.arrayBuffer()), kind, ownerId); +} + +/** + * Same as `storeImage`, but for bytes we already hold - for instance an image + * the server fetched from an import source. The size limit still applies. + */ +export async function storeImageBytes( + bytes: Buffer, + kind: UploadKind, + ownerId: string, +): Promise { + if (bytes.byteLength === 0) throw new UploadError('Die Datei ist leer.'); + if (bytes.byteLength > env.MAX_UPLOAD_BYTES) { + throw new UploadError( + `Das Bild ist größer als ${env.MAX_UPLOAD_MB} MB und wurde nicht übernommen.`, + ); + } - const bytes = Buffer.from(await file.arrayBuffer()); const mime = detectMimeType(bytes); if (!mime || !IMAGE_MIME_TYPES.includes(mime as (typeof IMAGE_MIME_TYPES)[number])) { throw new UploadError('Erlaubt sind nur JPEG-, PNG- und WebP-Bilder.'); diff --git a/tests/setup-env.ts b/tests/setup-env.ts new file mode 100644 index 0000000..b19663f --- /dev/null +++ b/tests/setup-env.ts @@ -0,0 +1,17 @@ +/** + * Baseline environment for unit tests. + * + * src/env.ts validates on import and refuses to start on a bad configuration - + * which is the point in production, but in tests every module that reads the + * environment would fail before its first assertion. These are placeholders, + * never anything a test asserts on. + * + * BASE_URL is overwritten unconditionally: Vite uses that very name for its + * 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'; +process.env.BASE_URL = 'http://localhost:3000'; +process.env.BETTER_AUTH_SECRET = + process.env.BETTER_AUTH_SECRET || 'test-secret-mindestens-32-zeichen-lang-0123'; +process.env.DATA_DIR = process.env.DATA_DIR || './data-test'; diff --git a/tests/stubs/server-only.ts b/tests/stubs/server-only.ts new file mode 100644 index 0000000..a931fa3 --- /dev/null +++ b/tests/stubs/server-only.ts @@ -0,0 +1,8 @@ +/** + * Stub for the `server-only` marker package. + * + * The real module throws on import outside a React Server Component, which + * would make every server module untestable. Importing it is a build-time + * guard, not behaviour, so an empty module is the honest replacement here. + */ +export {}; diff --git a/tests/unit/rebrickable.test.ts b/tests/unit/rebrickable.test.ts new file mode 100644 index 0000000..4d448b7 --- /dev/null +++ b/tests/unit/rebrickable.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The module reads the key through src/env, which validates on import - so +// the variable has to be in place before the dynamic import below. +process.env.REBRICKABLE_API_KEY = 'test-key'; + +const { + fetchImageBytes, + fetchMinifigCount, + fetchSet, + fetchTheme, + isRebrickableEnabled, + RebrickableError, +} = await import('@/lib/rebrickable'); + +type FetchArgs = Parameters; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const originalFetch = globalThis.fetch; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function stubFetch(handler: (url: string, init?: FetchArgs[1]) => Response | Promise) { + const spy = vi.fn(async (input: FetchArgs[0], init?: FetchArgs[1]) => + handler(String(input), init), + ); + globalThis.fetch = spy as unknown as typeof fetch; + return spy; +} + +const SET_RESPONSE = { + set_num: '60398-1', + name: 'Familienhaus und Elektroauto', + year: 2023, + theme_id: 52, + num_parts: 462, + set_img_url: 'https://cdn.rebrickable.com/media/sets/60398-1.jpg', + set_url: 'https://rebrickable.com/sets/60398-1/', +}; + +describe('Verfuegbarkeit', () => { + it('ist mit gesetztem API-Key aktiv', () => { + expect(isRebrickableEnabled()).toBe(true); + }); +}); + +describe('Set abrufen', () => { + it('uebersetzt die Antwort in unser Format', async () => { + stubFetch(() => jsonResponse(SET_RESPONSE)); + + const result = await fetchSet('60398'); + + expect(result).toEqual({ + setNumber: '60398', + name: 'Familienhaus und Elektroauto', + year: 2023, + pieceCount: 462, + themeId: 52, + imageUrl: 'https://cdn.rebrickable.com/media/sets/60398-1.jpg', + externalUrl: 'https://rebrickable.com/sets/60398-1/', + }); + }); + + it('fragt die Nummer mit Variante ab und schickt den Key im Header', async () => { + const spy = stubFetch(() => jsonResponse(SET_RESPONSE)); + + await fetchSet('60398'); + + const [url, init] = spy.mock.calls[0]!; + expect(String(url)).toContain('/sets/60398-1/'); + // Der Key gehoert in den Header, nicht in die URL - sonst steht er in + // jedem Proxy-Log. + expect(String(url)).not.toContain('test-key'); + expect((init?.headers as Record).Authorization).toBe('key test-key'); + }); + + it('liefert null, wenn Rebrickable die Nummer nicht kennt', async () => { + stubFetch(() => new Response('', { status: 404 })); + + await expect(fetchSet('99999')).resolves.toBeNull(); + }); + + it('meldet einen abgelehnten API-Key verstaendlich', async () => { + stubFetch(() => new Response('', { status: 401 })); + + await expect(fetchSet('60398')).rejects.toBeInstanceOf(RebrickableError); + await expect(fetchSet('60398')).rejects.toThrow(/API-Key/); + }); + + it('meldet eine Drosselung als eigenen Fall', async () => { + stubFetch(() => new Response('', { status: 429 })); + + await expect(fetchSet('60398')).rejects.toThrow(/drosselt/); + }); + + it('macht aus einem Netzwerkfehler keine Ausnahme mit Innenleben', async () => { + stubFetch(() => { + throw new Error('ECONNREFUSED'); + }); + + await expect(fetchSet('60398')).rejects.toThrow(/nicht erreichbar/); + }); + + it('kommt mit fehlenden Feldern klar', async () => { + stubFetch(() => + jsonResponse({ set_num: '1234-1', name: 'Ohne alles', year: null, theme_id: null }), + ); + + const result = await fetchSet('1234'); + + expect(result).toMatchObject({ + setNumber: '1234', + pieceCount: null, + themeId: null, + imageUrl: null, + }); + }); +}); + +describe('Thema abrufen', () => { + it('liefert Name und Oberthema', async () => { + stubFetch(() => jsonResponse({ id: 52, name: 'City', parent_id: 50 })); + + await expect(fetchTheme(52)).resolves.toEqual({ id: 52, name: 'City', parentId: 50 }); + }); + + it('behandelt ein Thema ohne Oberthema', async () => { + stubFetch(() => jsonResponse({ id: 1, name: 'Technic', parent_id: null })); + + await expect(fetchTheme(1)).resolves.toEqual({ id: 1, name: 'Technic', parentId: null }); + }); +}); + +describe('Minifiguren zaehlen', () => { + it('summiert die Mengen', async () => { + stubFetch(() => jsonResponse({ results: [{ quantity: 2 }, { quantity: 3 }] })); + + await expect(fetchMinifigCount('60398')).resolves.toBe(5); + }); + + it('laesst den Import nicht scheitern, wenn die Liste fehlt', async () => { + stubFetch(() => new Response('', { status: 500 })); + + // null statt Ausnahme: ein Set ohne Minifiguren-Angabe ist kein Fehler. + await expect(fetchMinifigCount('60398')).resolves.toBeNull(); + }); +}); + +describe('Bild laden', () => { + it('gibt die Bytes zurueck', async () => { + stubFetch(() => new Response(new Uint8Array([1, 2, 3]))); + + const bytes = await fetchImageBytes('https://example.test/bild.jpg'); + + expect(bytes?.byteLength).toBe(3); + }); + + it('gibt null zurueck, statt den Import scheitern zu lassen', async () => { + stubFetch(() => new Response('', { status: 404 })); + await expect(fetchImageBytes('https://example.test/weg.jpg')).resolves.toBeNull(); + + stubFetch(() => { + throw new Error('timeout'); + }); + await expect(fetchImageBytes('https://example.test/lahm.jpg')).resolves.toBeNull(); + }); +}); diff --git a/tests/unit/set-number.test.ts b/tests/unit/set-number.test.ts new file mode 100644 index 0000000..20a4b0d --- /dev/null +++ b/tests/unit/set-number.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { displaySetNumber, legoInstructionsUrl, normalizeSetNumber } from '@/lib/set-number'; + +describe('Setnummern fuer den Rebrickable-Import', () => { + it('haengt die Variante an, wenn nur die Packungsnummer eingetippt wurde', () => { + expect(normalizeSetNumber('60398')).toBe('60398-1'); + expect(normalizeSetNumber('71427')).toBe('71427-1'); + }); + + it('laesst eine bereits vollstaendige Nummer unveraendert', () => { + expect(normalizeSetNumber('60398-1')).toBe('60398-1'); + expect(normalizeSetNumber('10265-2')).toBe('10265-2'); + }); + + it('ignoriert Leerzeichen und vereinheitlicht Grossschreibung', () => { + expect(normalizeSetNumber(' 60398 ')).toBe('60398-1'); + expect(normalizeSetNumber('k8672')).toBe('K8672-1'); + }); + + it('entfernt fuer die Anzeige nur die erste Variante', () => { + expect(displaySetNumber('60398-1')).toBe('60398'); + // -2 ist eine echte zweite Variante und muss sichtbar bleiben. + expect(displaySetNumber('10265-2')).toBe('10265-2'); + expect(displaySetNumber('60398')).toBe('60398'); + }); + + it('ueberlebt den Rundweg Anzeige -> API -> Anzeige', () => { + for (const number of ['60398', '71427', '42172']) { + expect(displaySetNumber(normalizeSetNumber(number))).toBe(number); + } + }); +}); + +describe('Link auf die LEGO-Bauanleitung', () => { + it('nutzt die Nummer ohne Variante', () => { + expect(legoInstructionsUrl('60398-1')).toBe( + 'https://www.lego.com/de-de/service/building-instructions/60398', + ); + }); + + it('maskiert Sonderzeichen in der Nummer', () => { + expect(legoInstructionsUrl('2 30500')).toContain('2%2030500'); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index c058104..2fe7f3d 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -8,10 +8,14 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(root, './src'), + // `server-only` is a build-time marker that throws when imported outside + // a server component. Stubbing it keeps server modules testable. + 'server-only': path.resolve(root, './tests/stubs/server-only.ts'), }, }, test: { environment: 'node', + setupFiles: ['./tests/setup-env.ts'], include: ['tests/unit/**/*.test.ts'], globals: false, },