feat(import): lego-sets ueber die rebrickable-api uebernehmen

Auf "Neues Set" reicht die Setnummer von der Packung: Name, Jahr,
Teile, Minifiguren und Thema kommen aus der API, das Bild wird lokal
gespeichert und nicht nur verlinkt. Hersteller und Thema werden bei
Bedarf angelegt, ein Oberthema inklusive. Alle Aufrufe laufen
serverseitig; der Browser spricht nie mit rebrickable.com.

Ohne REBRICKABLE_API_KEY bleibt der Import sichtbar, aber abgeschaltet,
mit dem Hinweis, was zu tun ist - von Hand anlegen geht unveraendert.

Rebrickable liefert keine Anleitungsdateien. Statt das zu verschleiern,
legt der Import einen externen Link auf die LEGO-Serviceseite zur
Setnummer an.

Nebenbei: src/env.ts behandelt leere Werte jetzt wie nicht gesetzt - ein
"BASE_URL=" in der .env fuehrte sonst zum Start-Abbruch mit "Invalid
URL" statt zum Standardwert.

14 neue Unit-Tests decken Antwortverarbeitung, Setnummern-Varianten und
alle Fehlerfaelle der API ab (404, 401, 429, Netzwerk).

Refs Phase 2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-07 07:12:22 +02:00
co-authored by Claude Opus 5
parent 52d69e5654
commit 4e3cbb81ce
13 changed files with 813 additions and 6 deletions
+4 -2
View File
@@ -27,6 +27,8 @@ TZ=Europe/Berlin
# BIND_ADDRESS=127.0.0.1 # BIND_ADDRESS=127.0.0.1
# HOST_PORT=3000 # HOST_PORT=3000
# Optional (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import. # Optional: Rebrickable-API-Key fuer den Lego-Import. Damit reicht auf der
# Ohne Key funktioniert alles manuell weiter. # 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= # REBRICKABLE_API_KEY=
+237
View File
@@ -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<string | null> {
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<ActionResult<ImportResult>> {
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,
},
});
}
+19 -2
View File
@@ -1,8 +1,10 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { RebrickableImport } from '@/components/katalog/rebrickable-import';
import { SetForm } from '@/components/katalog/set-form'; import { SetForm } from '@/components/katalog/set-form';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { listManufacturers, listThemes } from '@/lib/queries/master-data'; import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { isRebrickableEnabled } from '@/lib/rebrickable';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Neues Set' }; export const metadata: Metadata = { title: 'Neues Set' };
@@ -21,12 +23,19 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
manufacturerId: row.manufacturerId, manufacturerId: row.manufacturerId,
})); }));
const importEnabled = isRebrickableEnabled();
if (manufacturers.length === 0) { if (manufacturers.length === 0) {
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1> <h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
{/* The Lego import creates its manufacturer itself, so it works even
on a completely empty instance. */}
<RebrickableImport enabled={importEnabled} />
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground"> <p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
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. was auch immer im Kinderzimmer steht.
</p> </p>
<Button asChild size="lg"> <Button asChild size="lg">
@@ -45,6 +54,14 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
Sammlung hinzufügen“ fest. Sammlung hinzufügen“ fest.
</p> </p>
</div> </div>
<RebrickableImport enabled={importEnabled} />
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
oder von Hand
<span className="h-px flex-1 bg-border" />
</div>
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} /> <SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} />
</div> </div>
); );
@@ -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 (
<Card className="border-dashed">
<CardHeader>
<CardTitle>Lego-Import</CardTitle>
<CardDescription>
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{' '}
<code className="font-mono text-xs">REBRICKABLE_API_KEY</code> in die{' '}
<code className="font-mono text-xs">.env</code> eintragen und den Container neu
starten.
</CardDescription>
</CardHeader>
</Card>
);
}
function submit(event: React.FormEvent<HTMLFormElement>) {
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 (
<Card>
<CardHeader>
<CardTitle>Lego-Import</CardTitle>
<CardDescription>
Setnummer von der Packung eintippen – Name, Jahr, Teile, Minifiguren, Thema und Bild
werden übernommen. Das Bild wird lokal gespeichert.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="flex items-end gap-2">
<Field label="Setnummer" htmlFor="import-number" className="flex-1">
<Input
id="import-number"
value={setNumber}
onChange={(event) => setSetNumber(event.target.value)}
placeholder="60398"
inputMode="numeric"
maxLength={40}
className="font-mono"
/>
</Field>
<Button type="submit" size="lg" disabled={pending || setNumber.trim().length === 0}>
{pending ? <Loader2 className="animate-spin" /> : <Download />}
Holen
</Button>
</form>
</CardContent>
</Card>
);
}
+16 -1
View File
@@ -31,8 +31,23 @@ const schema = z.object({
REBRICKABLE_API_KEY: z.string().min(1).optional(), 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<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(source)) {
if (typeof value === 'string' && value.trim() !== '') result[key] = value;
}
return result;
}
function parseEnv() { function parseEnv() {
const parsed = schema.safeParse(process.env); const parsed = schema.safeParse(withoutEmptyValues(process.env));
if (!parsed.success) { if (!parsed.success) {
const issues = parsed.error.issues const issues = parsed.error.issues
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`) .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
+140
View File
@@ -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<T>(path: string): Promise<T | null> {
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<RebrickableSet | null> {
const data = await request<SetResponse>(`/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<RebrickableTheme | null> {
const data = await request<ThemeResponse>(`/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<number | null> {
try {
const data = await request<MinifigsResponse>(
`/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<Buffer | null> {
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;
}
}
+32
View File
@@ -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),
)}`;
}
+18 -1
View File
@@ -88,8 +88,25 @@ export type StoredImage = {
*/ */
export async function storeImage(file: File, kind: UploadKind, ownerId: string): Promise<StoredImage> { export async function storeImage(file: File, kind: UploadKind, ownerId: string): Promise<StoredImage> {
assertSize(file); 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<StoredImage> {
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); const mime = detectMimeType(bytes);
if (!mime || !IMAGE_MIME_TYPES.includes(mime as (typeof IMAGE_MIME_TYPES)[number])) { if (!mime || !IMAGE_MIME_TYPES.includes(mime as (typeof IMAGE_MIME_TYPES)[number])) {
throw new UploadError('Erlaubt sind nur JPEG-, PNG- und WebP-Bilder.'); throw new UploadError('Erlaubt sind nur JPEG-, PNG- und WebP-Bilder.');
+17
View File
@@ -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';
+8
View File
@@ -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 {};
+179
View File
@@ -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<typeof fetch>;
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<Response>) {
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<string, string>).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();
});
});
+44
View File
@@ -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');
});
});
+4
View File
@@ -8,10 +8,14 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(root, './src'), '@': 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: { test: {
environment: 'node', environment: 'node',
setupFiles: ['./tests/setup-env.ts'],
include: ['tests/unit/**/*.test.ts'], include: ['tests/unit/**/*.test.ts'],
globals: false, globals: false,
}, },